|
73 | 73 |
|
74 | 74 | CreateDialogIndirectParam, DestroyWindow,
|
75 | 75 | ShowWindow, UpdateWindow, MSG, GetMessage, IsDialogMessage, TranslateMessage, DispatchMessage,
|
76 |
| - LPWINDOWPOS |
| 76 | + LPWINDOWPOS, |
| 77 | + # dockable dialog |
| 78 | + ExtendedWindowStyles as WS_EX, |
| 79 | + DMN, NPPM, TbData, DWS, DWS_DF_CONT, RECT, |
| 80 | + SendMessage, SetBkColor, SetTextColor, CreateSolidBrush, |
| 81 | + LOWORD, HIWORD |
77 | 82 | )
|
78 | 83 | from .controls.__control_template import Control
|
79 | 84 | from .controls.button import Button, DefaultButton, CheckBoxButton, GroupBox, CommandButton, RadioButton, RadioPushButton, SplitButton
|
|
105 | 110 |
|
106 | 111 | from Npp import notepad
|
107 | 112 | import ctypes
|
108 |
| -from ctypes import wintypes, create_unicode_buffer, pointer, cast |
| 113 | +from ctypes import wintypes, create_unicode_buffer, pointer, cast, addressof |
109 | 114 | from dataclasses import dataclass, field
|
110 | 115 | from typing import Dict, List
|
| 116 | +import threading |
| 117 | + |
111 | 118 |
|
112 | 119 | def registerHotkey(hotkey):
|
113 | 120 | def wrapper(func):
|
@@ -514,6 +521,336 @@ def __create_dialog(self):
|
514 | 521 | dialog = dlg_window + controls
|
515 | 522 | return dialog
|
516 | 523 |
|
| 524 | +class DockableDialog(threading.Thread): |
| 525 | + ''' |
| 526 | + Dialog Class |
| 527 | +
|
| 528 | + Represents a Npp-dockable dialog window. |
| 529 | +
|
| 530 | + The Dialog class provides a template for creating and managing dockable dialog windows. |
| 531 | + It encapsulates properties and behaviors common to dialog windows, such as |
| 532 | + title, size, position, styles, and controls. |
| 533 | +
|
| 534 | + Attributes: |
| 535 | + title (str): The title of the dialog window. |
| 536 | + size ((int, int)): The width and height of the dialog window. |
| 537 | + position ((int, int)): The x and y coordinates of the dialog window. |
| 538 | + styles (int): The style flags for the dialog window. |
| 539 | + exStyle (int): The extended style flags for the dialog window. |
| 540 | + pointsize (int): The font size for the dialog window. |
| 541 | + typeface (str): The font typeface for the dialog window. |
| 542 | + weight (int): The font weight for the dialog window. |
| 543 | + italic (int): The font italicization for the dialog window. |
| 544 | + charset (int): The character set for the dialog window. |
| 545 | +
|
| 546 | + parent (int): The handle of the parent window for the dialog. |
| 547 | + center (bool): Indicates whether the dialog should be centered on the screen. |
| 548 | + controlList (List): A list of control instances to be added to the dialog. |
| 549 | + controlStartId (int): The starting identifier for controls in the dialog. |
| 550 | + hwnd (int): The handle of the dialog window. |
| 551 | + registeredCommands (Dict): A dictionary of registered command messages and their associated handlers. |
| 552 | + registeredNotifications (Dict): A dictionary of registered notification messages and their associated handlers. |
| 553 | + initialize (Callable): A callback function called during the initialization of the dialog. |
| 554 | + closeOnEscapeKey (Bool): Specifies whether the dialog should be closed when the escape key is pressed. (defaults to True) |
| 555 | +
|
| 556 | + Note: |
| 557 | + The Dialog class is intended to be subclassed for specific dialog implementations. |
| 558 | + It provides a base template and common functionality for creating dialog windows. |
| 559 | + ''' |
| 560 | + |
| 561 | + def __init__(self, **kwargs): |
| 562 | + threading.Thread.__init__(self) |
| 563 | + self.title = kwargs.get('title', '') |
| 564 | + self.size = (300, 400) |
| 565 | + self.position = (0, 0) |
| 566 | + self.style = DS.SETFONT | WS.POPUP | WS.CAPTION | WS.SYSMENU |
| 567 | + self.exStyle = WS_EX.TOOLWINDOW | WS_EX.WINDOWEDGE |
| 568 | + self.pointsize = 9 |
| 569 | + self.typeface = 'Segoe UI' |
| 570 | + self.weight = 0 |
| 571 | + self.italic = 0 |
| 572 | + self.charset = 0 |
| 573 | + |
| 574 | + self.parent = notepad.hwnd |
| 575 | + self.controlList = [] |
| 576 | + self.controlStartId = 1025 |
| 577 | + self.hwnd = 0 |
| 578 | + self.registeredCommands = {} |
| 579 | + self.registeredNotifications = {} |
| 580 | + |
| 581 | + self.isVisible = False |
| 582 | + self.focusEditor = False |
| 583 | + self.useThemeColors = False |
| 584 | + self.themedBackgroundColor = notepad.getEditorDefaultBackgroundColor() |
| 585 | + self.themedForegroundColor = notepad.getEditorDefaultForegroundColor() |
| 586 | + |
| 587 | + self.tbdata = TbData() |
| 588 | + # hClient and pszName are set in run() |
| 589 | + self.tbdata.dlgID = -1 |
| 590 | + self.tbdata.uMask = DWS_DF_CONT.BOTTOM | DWS.ADDINFO | DWS.USEOWNDARKMODE |
| 591 | + self.tbdata.hIconTab = None |
| 592 | + |
| 593 | + self.add_info = ctypes.create_unicode_buffer(1000) |
| 594 | + self.add_info.value = 'hello' |
| 595 | + self.tbdata.pszAddInfo = ctypes.cast(ctypes.pointer(self.add_info), ctypes.POINTER(ctypes.c_wchar)) |
| 596 | + |
| 597 | + self.tbdata.rcFloat = RECT() |
| 598 | + self.tbdata.iPrevCont = -1 |
| 599 | + self.tbdata.pszModuleName = "PythonScript" |
| 600 | + |
| 601 | + def initialize(self): |
| 602 | + ''' |
| 603 | + Initializes the dialog and its controls at runtime. |
| 604 | +
|
| 605 | + This method is intended to be overridden by a concrete class. |
| 606 | + It is executed after all controls have been created but before the dialog is displayed. |
| 607 | + Concrete implementations should provide custom logic to set up initial values, states, and configurations of the controls. |
| 608 | +
|
| 609 | + Args: |
| 610 | + None. |
| 611 | +
|
| 612 | + Returns: |
| 613 | + None |
| 614 | + ''' |
| 615 | + pass |
| 616 | + |
| 617 | + def setTitle(self, new_title): |
| 618 | + """ |
| 619 | + Sets the title of the dialog window. |
| 620 | +
|
| 621 | + Args: |
| 622 | + text (str): The text to be set in the dialog window. |
| 623 | +
|
| 624 | + Returns: |
| 625 | + None |
| 626 | + """ |
| 627 | + SetWindowText(self.hwnd, new_title) |
| 628 | + |
| 629 | + def __create_dialog_window(self): |
| 630 | + ''' |
| 631 | + Create the dialog template structure. |
| 632 | +
|
| 633 | + Args: |
| 634 | + None. |
| 635 | +
|
| 636 | + Returns: |
| 637 | + bytearray: The byte array representing the dialog template structure. |
| 638 | +
|
| 639 | + ''' |
| 640 | + # https://learn.microsoft.com/en-us/windows/win32/dlgbox/dlgtemplateex |
| 641 | + self.windowClass = 0 |
| 642 | + _array = bytearray() |
| 643 | + _array += wintypes.WORD(1) # dlgVer |
| 644 | + _array += wintypes.WORD(0xFFFF) # signature |
| 645 | + _array += wintypes.DWORD(0) # helpID |
| 646 | + _array += wintypes.DWORD(self.exStyle) |
| 647 | + _array += wintypes.DWORD(self.style) |
| 648 | + _array += wintypes.WORD(self.dialog_items or 0) # cDlgItems |
| 649 | + _array += wintypes.SHORT(self.position[0]) # x |
| 650 | + _array += wintypes.SHORT(self.position[1]) # y |
| 651 | + _array += wintypes.SHORT(self.size[0]) # width |
| 652 | + _array += wintypes.SHORT(self.size[1]) # height |
| 653 | + _array += wintypes.WORD(0) # menu |
| 654 | + _array += wintypes.WORD(0) # windowClass |
| 655 | + _array += create_unicode_buffer(self.title) |
| 656 | + _array += wintypes.WORD(self.pointsize) |
| 657 | + _array += wintypes.WORD(self.weight) |
| 658 | + _array += wintypes.BYTE(self.italic) |
| 659 | + _array += wintypes.BYTE(self.charset) |
| 660 | + _array += create_unicode_buffer(self.typeface) |
| 661 | + return _array |
| 662 | + |
| 663 | + def __align_struct(self, tmp): |
| 664 | + ''' |
| 665 | + Aligns the template structure to the size of a DWORD. |
| 666 | +
|
| 667 | + Args: |
| 668 | + tmp: The template structure to be aligned. |
| 669 | +
|
| 670 | + Returns: |
| 671 | + The aligned template structure. |
| 672 | + ''' |
| 673 | + dword_size = ctypes.sizeof(wintypes.DWORD) |
| 674 | + align = dword_size - len(tmp) % dword_size |
| 675 | + if align < dword_size: |
| 676 | + tmp += bytearray(align) |
| 677 | + return tmp |
| 678 | + |
| 679 | + def toggle(self): |
| 680 | + SendMessage(self.parent, NPPM.DMMHIDE if self.isVisible else NPPM.DMMSHOW, 0, self.hwnd) |
| 681 | + self.isVisible = not self.isVisible |
| 682 | + |
| 683 | + def update_additional_info(self, new_message=""): |
| 684 | + if self.hwnd: |
| 685 | + self.add_info.value = new_message[:1000] |
| 686 | + SendMessage(self.parent, NPPM.DMMUPDATEDISPINFO, 0, self.hwnd) |
| 687 | + |
| 688 | + def dock(self): |
| 689 | + self.tbdata.hClient = self.hwnd |
| 690 | + self.tbdata.pszName = self.title |
| 691 | + SendMessage(self.parent, NPPM.DMMREGASDCKDLG, 0, addressof(self.tbdata)) |
| 692 | + if self.focusEditor: |
| 693 | + editor.grabFocus() |
| 694 | + |
| 695 | + def unregister(self): |
| 696 | + self.tbdata.hClient = self.hwnd |
| 697 | + self.tbdata.pszName = self.title |
| 698 | + SendMessage(self.parent, NPPM.DMMREGASDCKDLG, 0, addressof(self.tbdata)) |
| 699 | + if self.focusEditor: |
| 700 | + editor.grabFocus() |
| 701 | + |
| 702 | + |
| 703 | + def __default_dialog_proc(self, hwnd, msg, wparam, lparam): |
| 704 | + match msg: |
| 705 | + case WM.INITDIALOG: |
| 706 | + self.hwnd = hwnd |
| 707 | + for i, control in enumerate(self.controlList): |
| 708 | + self.controlList[i].hwnd = GetDlgItem(hwnd, control.id) |
| 709 | + |
| 710 | + self.initialize() |
| 711 | + # res = SendMessage(self.parent, NPPM.MODELESSDIALOG, 0, self.hwnd) # MODELESSDIALOGADD=0 MODELESSDIALOGREMOVE=1 |
| 712 | + # print(f'NPPM.MODELESSDIALOG {res=}') |
| 713 | + SetWindowPos(hwnd, 0, 0, 0, 0, 0, SWP.NOMOVE | SWP.NOSIZE | SWP.NOZORDER | SWP.FRAMECHANGED) |
| 714 | + return 1 |
| 715 | + |
| 716 | + # case WM.PAINT: |
| 717 | + # ps = PAINTSTRUCT() |
| 718 | + # p_ps = pointer(ps) |
| 719 | + # BeginPaint(self.hwnd, p_ps) |
| 720 | + # EndPaint(self.hwnd, p_ps) |
| 721 | + |
| 722 | + case WM.SIZE: |
| 723 | + # reposition all elements |
| 724 | + new_width = LOWORD(lparam) |
| 725 | + new_height = HIWORD(lparam) |
| 726 | + # RedrawWindow(self.hwnd, None, None, 1) |
| 727 | + if new_height or new_width: |
| 728 | + pass # silence the linter |
| 729 | + |
| 730 | + case WM.COMMAND: |
| 731 | + if wparam in self.registeredCommands: |
| 732 | + self.registeredCommands[wparam]() |
| 733 | + return 1 |
| 734 | + |
| 735 | + case [WM.CTLCOLOREDIT, WM.CTLCOLORLISTBOX, WM.CTLCOLORBTN, WM.CTLCOLORDLG, WM.CTLCOLORSTATIC]: |
| 736 | + if not self.useThemeColors: |
| 737 | + return 0 |
| 738 | + hdc = cast(wparam, HDC) |
| 739 | + SetTextColor(hdc, wintypes.RGB(*self.themedForegroundColor)) |
| 740 | + SetBkColor(hdc, wintypes.RGB(*self.themedBackgroundColor)) |
| 741 | + return CreateSolidBrush(wintypes.RGB(*self.themedBackgroundColor)) |
| 742 | + |
| 743 | + case WM.CTLCOLORSCROLLBAR: |
| 744 | + if not self.useThemeColors: |
| 745 | + return 0 |
| 746 | + hdc = cast(wparam, HDC) |
| 747 | + SetTextColor(hdc, wintypes.RGB(*self.themedBackgroundColor)) |
| 748 | + SetBKColor(hdc, wintypes.RGB(*self.themedForegroundColor)) |
| 749 | + return CreateSolidBrush(wintypes.RGB(*self.themedForegroundColor)) |
| 750 | + |
| 751 | + case WM.NOTIFY: |
| 752 | + lpnmhdr = ctypes.cast(lparam, LPNMHDR) |
| 753 | + __msg = LOWORD(lpnmhdr.contents.code) |
| 754 | + match __msg: |
| 755 | + case DMN.CLOSE: |
| 756 | + self.isVisible = False |
| 757 | + case DMN.FLOAT: |
| 758 | + self.isVisible = True |
| 759 | + case DMN.DOCK: |
| 760 | + self.isVisible = True |
| 761 | + notif_key = (lpnmhdr.contents.code, lpnmhdr.contents.idFrom) |
| 762 | + if notif_key in self.registeredNotifications: |
| 763 | + args = ctypes.cast(lparam, self.registeredNotifications[notif_key][1]) |
| 764 | + self.registeredNotifications[notif_key][0](args.contents) |
| 765 | + return 1 |
| 766 | + return 0 |
| 767 | + |
| 768 | + def show(self): |
| 769 | + ''' |
| 770 | + This method displays the dialog on the screen and starts its message loop, |
| 771 | + allowing user interaction with the controls. The method blocks until the |
| 772 | + dialog is closed. |
| 773 | +
|
| 774 | + Args: |
| 775 | + None. |
| 776 | +
|
| 777 | + Returns: |
| 778 | + None |
| 779 | + ''' |
| 780 | + # Instead of using dir(self), which always returns a sorted list, |
| 781 | + # __dict__.keys is used to maintain the order of control creation. |
| 782 | + for item in self.__dict__.keys(): |
| 783 | + obj = getattr(self, item) |
| 784 | + if isinstance(obj, Control): |
| 785 | + self.controlList.append(obj) |
| 786 | + |
| 787 | + self.start() # start the thread and create the dialog |
| 788 | + |
| 789 | + def run(self): |
| 790 | + """ |
| 791 | + Create the dialog window and its controls. |
| 792 | +
|
| 793 | + This method constructs the dialog window by creating its controls and setting up event handling. |
| 794 | + It iterates over the controlList, assigns unique IDs to the controls, creates control structures, |
| 795 | + aligns them to match memory requirements, and registers event handlers for commands and notifications. |
| 796 | +
|
| 797 | + Args: |
| 798 | + None. |
| 799 | +
|
| 800 | + Returns: |
| 801 | + None. |
| 802 | +
|
| 803 | + Raises: |
| 804 | + TypeError: If a control in controlList is not an instance of Control. |
| 805 | +
|
| 806 | + Notes: |
| 807 | + - This method is called internally during the creation of the Dialog object. |
| 808 | + - It utilizes the __align_struct method to ensure proper memory alignment. |
| 809 | + - The created dialog is displayed using the DialogBoxIndirectParam function. |
| 810 | + """ |
| 811 | + controls = bytearray() |
| 812 | + for i, control in enumerate(self.controlList): |
| 813 | + if not isinstance(control, Control): |
| 814 | + raise TypeError(f"{control} is not an instance of Control") |
| 815 | + control.id = self.controlStartId + i |
| 816 | + control_struct = control.create() |
| 817 | + controls += self.__align_struct(control_struct) |
| 818 | + for event, func in control.registeredCommands.items(): |
| 819 | + # mimicking what MS does internally allows us to directly use wparam in __default_dialog_proc |
| 820 | + self.registeredCommands[(event << 16) + control.id] = func |
| 821 | + |
| 822 | + for event, func in control.registeredNotifications.items(): |
| 823 | + self.registeredNotifications[(event, control.id)] = func |
| 824 | + |
| 825 | + self.dialog_items = len(self.controlList) |
| 826 | + dlg_window = self.__create_dialog_window() |
| 827 | + dlg_window = self.__align_struct(dlg_window) |
| 828 | + dialog = dlg_window + controls |
| 829 | + raw_bytes = (ctypes.c_ubyte * len(dialog)).from_buffer_copy(dialog) |
| 830 | + hinstance = GetModuleHandle(None) |
| 831 | + |
| 832 | + self.dialogProc = DIALOGPROC(self.__default_dialog_proc) # DO NOT REMOVE - otherwise ... crashing (!??) |
| 833 | + self.hwnd = CreateDialogIndirectParam(hinstance, |
| 834 | + raw_bytes, |
| 835 | + self.parent, |
| 836 | + self.dialogProc, |
| 837 | + 0) |
| 838 | + if self.hwnd: |
| 839 | + self.dock() |
| 840 | + ShowWindow(self.hwnd, 5) |
| 841 | + UpdateWindow(self.hwnd) |
| 842 | + |
| 843 | + msg = MSG() |
| 844 | + lpmsg = pointer(msg) |
| 845 | + |
| 846 | + while True: |
| 847 | + bRet = GetMessage(lpmsg, 0, 0, 0) |
| 848 | + if (bRet == 0) or (bRet == -1): |
| 849 | + break |
| 850 | + if not IsDialogMessage(self.hwnd, lpmsg): |
| 851 | + TranslateMessage(lpmsg) |
| 852 | + DispatchMessage(lpmsg) |
| 853 | + |
517 | 854 |
|
518 | 855 | def create_dialog_from_rc(rc_code):
|
519 | 856 | '''
|
|
0 commit comments