Skip to content

Commit f772d2d

Browse files
committed
Dockable dialogs - initial commit
1 parent 6859c4a commit f772d2d

File tree

2 files changed

+430
-3
lines changed

2 files changed

+430
-3
lines changed

helper/WinDialog/__init__.py

Lines changed: 339 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@
7373

7474
CreateDialogIndirectParam, DestroyWindow,
7575
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
7782
)
7883
from .controls.__control_template import Control
7984
from .controls.button import Button, DefaultButton, CheckBoxButton, GroupBox, CommandButton, RadioButton, RadioPushButton, SplitButton
@@ -105,9 +110,11 @@
105110

106111
from Npp import notepad
107112
import ctypes
108-
from ctypes import wintypes, create_unicode_buffer, pointer, cast
113+
from ctypes import wintypes, create_unicode_buffer, pointer, cast, addressof
109114
from dataclasses import dataclass, field
110115
from typing import Dict, List
116+
import threading
117+
111118

112119
def registerHotkey(hotkey):
113120
def wrapper(func):
@@ -514,6 +521,336 @@ def __create_dialog(self):
514521
dialog = dlg_window + controls
515522
return dialog
516523

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+
517854

518855
def create_dialog_from_rc(rc_code):
519856
'''

0 commit comments

Comments
 (0)