• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

adamws / kicad-kbplacer / 4c7a9423-9795-4c8a-9806-534434e1a1ae

28 Sep 2024 11:52AM UTC coverage: 94.709%. Remained the same
4c7a9423-9795-4c8a-9806-534434e1a1ae

push

circleci

web-flow
Translated using Weblate (Slovak) (#41)

Currently translated at 100.0% (16 of 16 strings)

Translation: KiCad kbplacer plugin/master source
Translate-URL: https://hosted.weblate.org/projects/kicad-kbplacer/master-source/sk/

Co-authored-by: Milan Šalka <salka.milan@googlemail.com>

2667 of 2816 relevant lines covered (94.71%)

0.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.98
/kbplacer/kbplacer_dialog.py
1
from __future__ import annotations
1✔
2

3
import gettext
1✔
4
import json
1✔
5
import logging
1✔
6
import os
1✔
7
import string
1✔
8
import sys
1✔
9
from dataclasses import asdict, dataclass, field
1✔
10
from enum import Flag
1✔
11
from typing import List, Optional, Tuple
1✔
12

13
import wx
1✔
14
from wx.lib.embeddedimage import PyEmbeddedImage
1✔
15

16
from .defaults import DEFAULT_DIODE_POSITION, ZERO_POSITION
1✔
17
from .element_position import ElementInfo, ElementPosition, PositionOption, Side
1✔
18
from .help_dialog import HelpDialog
1✔
19

20
logger = logging.getLogger(__name__)
1✔
21
TEXT_CTRL_EXTRA_SPACE = 25
1✔
22

23
# Most of the phrases used in this plugin are already in use in KiCad.
24
# It means that we get translations for free using `wx.GetTranslation`.
25
# All strings translated with 'wx_' are expected to be a part of
26
# KiCad's translation files. All remaining will be translated with
27
# another custom mechanism or will remain default.
28
wx_ = wx.GetTranslation
1✔
29

30
# Currently there is no elegant way to check which language is loaded by KiCad.
31
# This feature has been requested here:
32
#   https://gitlab.com/kicad/code/kicad/-/issues/10573
33
# Until then, use workaroud - request translation with wx_ and use result
34
# in lookup table. This lookup should contain all installed languages defined
35
# in translation/pofiles/LINGUAS_INSTALL.
36
KICAD_TRANSLATIONS_LOOKUP = {
1✔
37
    "Sprache": "de",
38
    "Set Language": "en",
39
    "Seleccionar idioma": "es",
40
    "言語設定": "ja",
41
    "언어 설정": "ko",
42
    "Taal instellen": "nl",
43
    "Ustaw język": "pl",
44
    "Установить язык": "ru",
45
    "Nastaviť jazyk": "sk",
46
    "Встановити мову": "uk",
47
    "設定語言": "zh_CN",
48
}
49

50

51
@dataclass
1✔
52
class WindowState:
1✔
53
    layout_path: str = ""
1✔
54
    key_distance: Tuple[float, float] = (19.05, 19.05)
1✔
55
    key_info: ElementInfo = field(
1✔
56
        default_factory=lambda: ElementInfo(
57
            "SW{}", PositionOption.DEFAULT, ZERO_POSITION, ""
58
        )
59
    )
60
    enable_diode_placement: bool = True
1✔
61
    route_switches_with_diodes: bool = True
1✔
62
    optimize_diodes_orientation: bool = False
1✔
63
    diode_info: ElementInfo = field(
1✔
64
        default_factory=lambda: ElementInfo(
65
            "D{}", PositionOption.DEFAULT, DEFAULT_DIODE_POSITION, ""
66
        )
67
    )
68
    additional_elements: List[ElementInfo] = field(
1✔
69
        default_factory=lambda: [
70
            ElementInfo("ST{}", PositionOption.CUSTOM, ZERO_POSITION, "")
71
        ]
72
    )
73
    route_rows_and_columns: bool = True
1✔
74
    template_path: str = ""
1✔
75
    generate_outline: bool = False
1✔
76
    outline_delta: float = 0.0
1✔
77

78
    def __str__(self) -> str:
1✔
79
        return json.dumps(asdict(self), indent=None)
1✔
80

81
    @classmethod
1✔
82
    def from_dict(cls, data: dict) -> WindowState:
1✔
83
        key_distance = data.pop("key_distance")
1✔
84
        if type(key_distance) is list:
1✔
85
            key_distance = tuple(key_distance)
1✔
86
        key_info = ElementInfo.from_dict(data.pop("key_info"))
1✔
87
        diode_info = ElementInfo.from_dict(data.pop("diode_info"))
1✔
88
        additional_elements = [
1✔
89
            ElementInfo.from_dict(i) for i in data.pop("additional_elements")
90
        ]
91
        return cls(
1✔
92
            key_distance=key_distance,
93
            key_info=key_info,
94
            diode_info=diode_info,
95
            additional_elements=additional_elements,
96
            **data,
97
        )
98

99

100
def get_current_kicad_language() -> str:
1✔
101
    kicad_lang = KICAD_TRANSLATIONS_LOOKUP.get(wx_("Set Language"), "en")
1✔
102
    # some languages require additional lookup to detect correctly,
103
    # for example es vs es_MX:
104
    if kicad_lang == "es" and wx_("Enabled:") == "Activado:":
1✔
105
        # es: Enabled -> Habilitado, es_MX: Enabled -> Activado
106
        kicad_lang = "es_MX"
×
107
    return kicad_lang
1✔
108

109

110
def get_plugin_translator(lang: str = "en"):
1✔
111
    localedir = os.path.join(os.path.dirname(__file__), "locale")
1✔
112
    trans = gettext.translation(
1✔
113
        "kbplacer", localedir=localedir, languages=(lang,), fallback=True
114
    )
115
    return trans.gettext
1✔
116

117

118
def get_file_picker(*args, **kwargs) -> wx.FilePickerCtrl:
1✔
119
    file_picker = wx.FilePickerCtrl(*args, **kwargs)
1✔
120
    file_picker.SetTextCtrlGrowable(True)
1✔
121

122
    def _update_position(_) -> None:
1✔
123
        text_ctrl = file_picker.GetTextCtrl()
×
124
        # when updating from file chooser window we want automatically
125
        # move to the end (it looks better when not whole path fits),
126
        # but when user updates by typing in text control we don't
127
        # want to mess up with it. This can be done by checking
128
        # if current insertion point is 0.
129
        if text_ctrl.GetInsertionPoint() == 0:
×
130
            text_ctrl.SetInsertionPointEnd()
×
131

132
    file_picker.Bind(wx.EVT_FILEPICKER_CHANGED, _update_position)
1✔
133
    return file_picker
1✔
134

135

136
class FloatValidator(wx.Validator):
1✔
137
    def __init__(self) -> None:
1✔
138
        wx.Validator.__init__(self)
1✔
139
        self.Bind(wx.EVT_CHAR, self.OnChar)
1✔
140

141
    def Clone(self) -> FloatValidator:
1✔
142
        return FloatValidator()
1✔
143

144
    def Validate(self, _) -> bool:
1✔
145
        text_ctrl = self.GetWindow()
×
146
        if not text_ctrl.IsEnabled():
×
147
            return True
×
148

149
        text = text_ctrl.GetValue()
×
150
        try:
×
151
            float(text)
×
152
            return True
×
153
        except ValueError:
×
154
            # this can happen when value is empty, equal '-', '.', or '-.',
155
            # other invalid values should not be allowed by 'OnChar' filtering
156
            name = text_ctrl.GetName()
×
157
            wx.MessageBox(f"Invalid '{name}' value: '{text}' is not a number!", "Error")
×
158
            text_ctrl.SetFocus()
×
159
            return False
×
160

161
    def TransferToWindow(self) -> bool:
1✔
162
        return True
1✔
163

164
    def TransferFromWindow(self) -> bool:
1✔
165
        return True
×
166

167
    def OnChar(self, event: wx.KeyEvent) -> None:
1✔
168
        text_ctrl = self.GetWindow()
×
169
        current_position = text_ctrl.GetInsertionPoint()
×
170
        keycode = int(event.GetKeyCode())
×
171
        if keycode in [
×
172
            wx.WXK_BACK,
173
            wx.WXK_DELETE,
174
            wx.WXK_LEFT,
175
            wx.WXK_RIGHT,
176
            wx.WXK_NUMPAD_LEFT,
177
            wx.WXK_NUMPAD_RIGHT,
178
            wx.WXK_TAB,
179
        ]:
180
            event.Skip()
×
181
        else:
182
            text_ctrl = self.GetWindow()
×
183
            text = text_ctrl.GetValue()
×
184
            key = chr(keycode)
×
185
            if (
×
186
                # allow only digits
187
                # or single '-' when as first character
188
                # or single '.'
189
                key in string.digits
190
                or (key == "-" and "-" not in text and current_position == 0)
191
                or (key == "." and "." not in text)
192
            ):
193
                event.Skip()
×
194

195

196
class LabeledTextCtrl(wx.Panel):
1✔
197
    def __init__(
1✔
198
        self,
199
        parent: wx.Window,
200
        label: str,
201
        value: str,
202
        width: int = -1,
203
        validator: wx.Validator = wx.DefaultValidator,
204
    ) -> None:
205
        super().__init__(parent)
1✔
206

207
        expected_char_width = self.GetTextExtent("x").x
1✔
208
        if width != -1:
1✔
209
            annotation_format_size = wx.Size(
1✔
210
                expected_char_width * width + TEXT_CTRL_EXTRA_SPACE, -1
211
            )
212
        else:
213
            annotation_format_size = wx.Size(-1, -1)
1✔
214

215
        self.label = wx.StaticText(self, -1, label)
1✔
216
        self.text = wx.TextCtrl(
1✔
217
            self,
218
            value=value,
219
            size=annotation_format_size,
220
            validator=validator,
221
            name=label.strip(":"),
222
        )
223

224
        sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
225
        sizer.Add(self.label, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 5)
1✔
226
        sizer.Add(self.text, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
1✔
227

228
        self.SetSizer(sizer)
1✔
229

230
    def Enable(self) -> None:
1✔
231
        self.label.Enable()
1✔
232
        self.text.Enable()
1✔
233

234
    def Disable(self) -> None:
1✔
235
        self.label.Disable()
1✔
236
        self.text.Disable()
1✔
237

238
    def Hide(self) -> None:
1✔
239
        self.label.Hide()
1✔
240
        self.text.Hide()
1✔
241

242

243
class CustomRadioBox(wx.Panel):
1✔
244
    def __init__(self, parent: wx.Window, choices: List[str]) -> None:
1✔
245
        super().__init__(parent)
1✔
246
        self.radio_buttons: dict[str, wx.RadioButton] = {}
1✔
247

248
        for choice in choices:
1✔
249
            radio_button = wx.RadioButton(self, label=choice)
1✔
250
            self.radio_buttons[choice] = radio_button
1✔
251

252
        # this is special hidden option to allow clearing (selecting none)
253
        self.none_button = wx.RadioButton(self, label="")
1✔
254
        self.none_button.Hide()
1✔
255

256
        # on linux, use negative border because otherwise it looks too spaced out
257
        space = 0 if sys.platform == "win32" else -2
1✔
258
        sizer = wx.BoxSizer(wx.VERTICAL)
1✔
259
        for radio_button in self.radio_buttons.values():
1✔
260
            sizer.Add(radio_button, 0, wx.TOP | wx.BOTTOM, space)
1✔
261

262
        self.SetSizer(sizer)
1✔
263

264
    def Select(self, choice: str) -> None:
1✔
265
        self.radio_buttons[choice].SetValue(True)
1✔
266

267
    def Clear(self) -> None:
1✔
268
        self.none_button.SetValue(True)
1✔
269

270
    def GetValue(self) -> Optional[str]:
1✔
271
        if not self.none_button.GetValue():
1✔
272
            for choice, button in self.radio_buttons.items():
1✔
273
                if button.GetValue():
1✔
274
                    return choice
1✔
275
        return None
×
276

277

278
class ElementPositionWidget(wx.Panel):
1✔
279
    def __init__(
1✔
280
        self,
281
        parent: wx.Window,
282
        default_position: Optional[ElementPosition] = None,
283
        disable_offsets: bool = False,
284
    ) -> None:
285
        super().__init__(parent)
1✔
286

287
        self.default = default_position
1✔
288
        self.x = LabeledTextCtrl(
1✔
289
            self, wx_("Offset X:"), value="", width=5, validator=FloatValidator()
290
        )
291
        self.y = LabeledTextCtrl(
1✔
292
            self, wx_("Y:"), value="", width=5, validator=FloatValidator()
293
        )
294
        self.orientation = LabeledTextCtrl(
1✔
295
            self, wx_("Orientation:"), value="", width=5, validator=FloatValidator()
296
        )
297
        if disable_offsets:
1✔
298
            self.x.Hide()
1✔
299
            self.y.Hide()
1✔
300
        self.side_label = wx.StaticText(self, -1, wx_("Side:"))
1✔
301
        self.side = CustomRadioBox(self, choices=[wx_("Front"), wx_("Back")])
1✔
302

303
        sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
304
        if not disable_offsets:
1✔
305
            sizer.Add(self.x, 0, wx.EXPAND | wx.LEFT, 5)
1✔
306
            sizer.Add(self.y, 0, wx.EXPAND | wx.LEFT, 5)
1✔
307
        sizer.Add(self.orientation, 0, wx.EXPAND | wx.LEFT, 5)
1✔
308
        sizer.Add(self.side_label, 0, wx.LEFT | wx.ALIGN_CENTER_VERTICAL, 5)
1✔
309
        sizer.Add(self.side, 0, wx.EXPAND | wx.LEFT, 5)
1✔
310

311
        self.SetSizer(sizer)
1✔
312

313
    def set_position_by_choice(self, choice: str) -> None:
1✔
314
        if choice == PositionOption.DEFAULT:
1✔
315
            self.__set_position_to_default()
1✔
316
        elif choice == PositionOption.CUSTOM:
1✔
317
            self.__set_position_to_zero_editable()
1✔
318
        else:
319
            self.__set_position_to_empty_non_editable()
1✔
320

321
    def set_position(self, position: ElementPosition) -> None:
1✔
322
        self.__set_coordinates(str(position.x), str(position.y))
1✔
323
        self.__set_orientation(str(position.orientation))
1✔
324
        self.__set_side(position.side)
1✔
325

326
    def __set_position_to_default(self) -> None:
1✔
327
        if self.default:
1✔
328
            x = str(self.default.x)
1✔
329
            y = str(self.default.y)
1✔
330
            self.__set_coordinates(x, y)
1✔
331
            self.__set_orientation(str(self.default.orientation))
1✔
332
            self.__set_side(self.default.side)
1✔
333
            self.Disable()
1✔
334

335
    def __set_position_to_zero_editable(self) -> None:
1✔
336
        self.__set_coordinates("0", "0")
1✔
337
        self.__set_orientation("0")
1✔
338
        self.__set_side(Side.FRONT)
1✔
339
        self.Enable()
1✔
340

341
    def __set_position_to_empty_non_editable(self) -> None:
1✔
342
        self.__set_coordinates("-", "-")
1✔
343
        self.__set_orientation("-")
1✔
344
        self.side.Clear()
1✔
345
        self.Disable()
1✔
346

347
    def __set_coordinates(self, x: str, y: str) -> None:
1✔
348
        self.x.text.SetValue(x)
1✔
349
        self.y.text.SetValue(y)
1✔
350

351
    def __set_orientation(self, orientation: str) -> None:
1✔
352
        self.orientation.text.SetValue(orientation)
1✔
353

354
    def __set_side(self, side: Side) -> None:
1✔
355
        if side == Side.BACK:
1✔
356
            self.side.Select(wx_("Back"))
1✔
357
        else:
358
            self.side.Select(wx_("Front"))
1✔
359

360
    def __get_side(self) -> Side:
1✔
361
        side_str = str(self.side.GetValue())
1✔
362
        if side_str == wx_("Back"):
1✔
363
            return Side.BACK
1✔
364
        else:
365
            return Side.FRONT
1✔
366

367
    def GetValue(self) -> ElementPosition:
1✔
368
        x = float(self.x.text.GetValue())
1✔
369
        y = float(self.y.text.GetValue())
1✔
370
        orientation = float(self.orientation.text.GetValue())
1✔
371
        return ElementPosition(x, y, orientation, self.__get_side())
1✔
372

373
    def Enable(self) -> None:
1✔
374
        self.x.Enable()
1✔
375
        self.y.Enable()
1✔
376
        self.orientation.Enable()
1✔
377
        self.side_label.Enable()
1✔
378
        self.side.Enable()
1✔
379

380
    def Disable(self) -> None:
1✔
381
        self.x.Disable()
1✔
382
        self.y.Disable()
1✔
383
        self.orientation.Disable()
1✔
384
        self.side_label.Disable()
1✔
385
        self.side.Disable()
1✔
386

387

388
class TemplateType(Flag):
1✔
389
    LOAD = False
1✔
390
    SAVE = True
1✔
391

392

393
class ElementTemplateSelectionWidget(wx.Panel):
1✔
394
    def __init__(
1✔
395
        self, parent: wx.Window, picker_type: TemplateType, initial_path: str = ""
396
    ) -> None:
397
        super().__init__(parent)
1✔
398
        self._ = self.GetTopLevelParent()._
1✔
399

400
        label = (
1✔
401
            self._("Save to:")
402
            if picker_type == TemplateType.SAVE
403
            else self._("Load from:")
404
        )
405
        layout_label = wx.StaticText(self, -1, label)
1✔
406
        layout_picker = get_file_picker(
1✔
407
            self,
408
            -1,
409
            wildcard="KiCad printed circuit board files (*.kicad_pcb)|*.kicad_pcb",
410
            style=wx.FLP_USE_TEXTCTRL
411
            | (wx.FLP_SAVE if picker_type == TemplateType.SAVE else wx.FLP_OPEN),
412
        )
413

414
        if initial_path:
1✔
415
            layout_picker.SetPath(initial_path)
1✔
416
            layout_picker.GetTextCtrl().SetInsertionPointEnd()
1✔
417

418
        sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
419
        sizer.Add(layout_label, 0, wx.LEFT | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 5)
1✔
420
        sizer.Add(layout_picker, 1, wx.EXPAND | wx.ALL, 5)
1✔
421
        self.SetSizer(sizer)
1✔
422

423
        self.__layout_picker = layout_picker
1✔
424

425
    def GetValue(self) -> str:
1✔
426
        return self.__layout_picker.GetPath()
1✔
427

428

429
class ElementPositionChoiceWidget(wx.Panel):
1✔
430
    def __init__(
1✔
431
        self,
432
        parent: wx.Window,
433
        initial_choice: PositionOption,
434
        initial_position: Optional[ElementPosition] = None,
435
        default_position: Optional[ElementPosition] = None,
436
        initial_path: str = "",
437
    ) -> None:
438
        super().__init__(parent)
1✔
439

440
        choices = [
1✔
441
            PositionOption.CUSTOM,
442
            PositionOption.RELATIVE,
443
            PositionOption.PRESET,
444
        ]
445
        if default_position:
1✔
446
            choices.insert(0, PositionOption.DEFAULT)
1✔
447

448
        self.dropdown = wx.ComboBox(self, choices=choices, style=wx.CB_DROPDOWN)
1✔
449
        self.dropdown.Bind(wx.EVT_COMBOBOX, self.__on_position_choice_change)
1✔
450

451
        dropdown_sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
452
        dropdown_sizer.Add(
1✔
453
            wx.StaticText(self, -1, wx_("Position") + ":"),
454
            0,
455
            wx.RIGHT | wx.ALIGN_CENTER_VERTICAL,
456
            5,
457
        )
458
        dropdown_sizer.Add(self.dropdown, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 5)
1✔
459

460
        self.position = ElementPositionWidget(self, default_position)
1✔
461
        self.load_template = ElementTemplateSelectionWidget(
1✔
462
            self, picker_type=TemplateType.LOAD, initial_path=initial_path
463
        )
464
        self.save_template = ElementTemplateSelectionWidget(
1✔
465
            self, picker_type=TemplateType.SAVE, initial_path=initial_path
466
        )
467

468
        self.__set_initial_state(initial_choice)
1✔
469
        if initial_position and initial_choice == PositionOption.CUSTOM:
1✔
470
            self.position.set_position(initial_position)
1✔
471

472
        sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
473
        sizer.Add(dropdown_sizer, 0, wx.EXPAND | wx.ALL, 5)
1✔
474
        sizer.Add(self.position, 0, wx.EXPAND | wx.ALL, 5)
1✔
475
        sizer.Add(self.load_template, 1, wx.EXPAND | wx.ALL, 5)
1✔
476
        sizer.Add(self.save_template, 1, wx.EXPAND | wx.ALL, 5)
1✔
477

478
        self.SetSizer(sizer)
1✔
479

480
    def __set_initial_state(self, choice: str) -> None:
1✔
481
        self.dropdown.SetValue(choice)
1✔
482
        self.__set_position_by_choice(choice)
1✔
483

484
    def __on_position_choice_change(self, event: wx.CommandEvent) -> None:
1✔
485
        choice = event.GetString()
×
486
        self.__set_position_by_choice(choice)
×
487

488
    def __set_position_by_choice(self, choice: str) -> None:
1✔
489
        if choice == PositionOption.RELATIVE:
1✔
490
            self.position.Hide()
1✔
491
            self.load_template.Hide()
1✔
492
            self.save_template.Show()
1✔
493
        elif choice == PositionOption.PRESET:
1✔
494
            self.position.Hide()
1✔
495
            self.load_template.Show()
1✔
496
            self.save_template.Hide()
1✔
497
        else:
498
            self.position.Show()
1✔
499
            self.load_template.Hide()
1✔
500
            self.save_template.Hide()
1✔
501
        self.GetTopLevelParent().Layout()
1✔
502
        self.position.set_position_by_choice(choice)
1✔
503
        self.choice = PositionOption(choice)
1✔
504

505
    def GetValue(self) -> Tuple[PositionOption, Optional[ElementPosition], str]:
1✔
506
        template_path = ""
1✔
507
        if self.dropdown.GetValue() == PositionOption.RELATIVE:
1✔
508
            template_path = self.save_template.GetValue()
1✔
509
        elif self.dropdown.GetValue() == PositionOption.PRESET:
1✔
510
            template_path = self.load_template.GetValue()
1✔
511

512
        if self.choice not in [PositionOption.DEFAULT, PositionOption.CUSTOM]:
1✔
513
            return self.choice, None, template_path
1✔
514
        return self.choice, self.position.GetValue(), template_path
1✔
515

516
    def Enable(self) -> None:
1✔
517
        self.dropdown.Enable()
1✔
518
        if self.dropdown.GetValue() == PositionOption.CUSTOM:
1✔
519
            self.position.Enable()
×
520
        self.load_template.Enable()
1✔
521
        self.save_template.Enable()
1✔
522

523
    def Disable(self) -> None:
1✔
524
        self.dropdown.Disable()
1✔
525
        self.position.Disable()
1✔
526
        self.load_template.Disable()
1✔
527
        self.save_template.Disable()
1✔
528

529

530
class ElementSettingsWidget(wx.Panel):
1✔
531
    def __init__(
1✔
532
        self,
533
        parent: wx.Window,
534
        element_info: ElementInfo,
535
        default_position: Optional[ElementPosition] = None,
536
    ) -> None:
537
        super().__init__(parent)
1✔
538

539
        self.annotation_format = LabeledTextCtrl(
1✔
540
            self,
541
            label=wx_("Footprint Annotation") + ":",
542
            value=element_info.annotation_format,
543
            width=3,
544
        )
545
        self.position_widget = ElementPositionChoiceWidget(
1✔
546
            self,
547
            element_info.position_option,
548
            element_info.position,
549
            default_position=default_position,
550
            initial_path=element_info.template_path,
551
        )
552

553
        sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
554

555
        sizer.Add(
1✔
556
            self.annotation_format, 0, wx.EXPAND | wx.TOP | wx.BOTTOM | wx.RIGHT, 5
557
        )
558
        sizer.Add(self.position_widget, 1, wx.EXPAND | wx.ALL, 0)
1✔
559

560
        self.SetSizer(sizer)
1✔
561

562
    def GetValue(self) -> ElementInfo:
1✔
563
        annotation = self.annotation_format.text.GetValue()
1✔
564
        position = self.position_widget.GetValue()
1✔
565
        return ElementInfo(annotation, *position)
1✔
566

567
    def Enable(self) -> None:
1✔
568
        self.position_widget.Enable()
1✔
569

570
    def Disable(self) -> None:
1✔
571
        self.position_widget.Disable()
1✔
572

573

574
class KbplacerDialog(wx.Dialog):
1✔
575
    def __init__(
1✔
576
        self,
577
        parent: Optional[wx.Window],
578
        title: str,
579
        initial_state: WindowState = WindowState(),
580
    ) -> None:
581
        style = wx.DEFAULT_DIALOG_STYLE
1✔
582
        super(KbplacerDialog, self).__init__(parent, -1, title, style=style)
1✔
583

584
        language = get_current_kicad_language()
1✔
585
        logger.info(f"Language: {language}")
1✔
586
        self._ = get_plugin_translator(language)
1✔
587

588
        switch_section = self.get_switch_section(
1✔
589
            layout_path=initial_state.layout_path,
590
            key_distance=initial_state.key_distance,
591
            element_info=initial_state.key_info,
592
        )
593

594
        switch_diodes_section = self.get_switch_diodes_section(
1✔
595
            enable=initial_state.enable_diode_placement,
596
            route_switches_with_diodes=initial_state.route_switches_with_diodes,
597
            optimize_diodes_orientation=initial_state.optimize_diodes_orientation,
598
            element_info=initial_state.diode_info,
599
        )
600

601
        additional_elements_section = self.get_additional_elements_section(
1✔
602
            elements_info=initial_state.additional_elements,
603
        )
604

605
        misc_section = self.get_misc_section(
1✔
606
            route_rows_and_columns=initial_state.route_rows_and_columns,
607
            template_path=initial_state.template_path,
608
            generate_outline=initial_state.generate_outline,
609
            outline_delta=initial_state.outline_delta,
610
        )
611

612
        box = wx.BoxSizer(wx.VERTICAL)
1✔
613

614
        box.Add(switch_section, 0, wx.EXPAND | wx.ALL, 5)
1✔
615
        box.Add(switch_diodes_section, 0, wx.EXPAND | wx.ALL, 5)
1✔
616
        box.Add(additional_elements_section, 0, wx.EXPAND | wx.ALL, 5)
1✔
617
        box.Add(misc_section, 0, wx.EXPAND | wx.ALL, 5)
1✔
618

619
        buttons = self.CreateButtonSizer(wx.OK | wx.CANCEL | wx.HELP)
1✔
620

621
        if help_button := wx.FindWindowById(wx.ID_HELP, self):
1✔
622
            help_button.Bind(wx.EVT_BUTTON, self.on_help_button)
1✔
623

624
        box.Add(buttons, 0, wx.EXPAND | wx.ALL, 5)
1✔
625

626
        self.SetSizerAndFit(box)
1✔
627

628
    def get_switch_section(
1✔
629
        self,
630
        layout_path: str = "",
631
        key_distance: Tuple[float, float] = (19.05, 19.05),
632
        element_info: ElementInfo = ElementInfo(
633
            "SW{}", PositionOption.DEFAULT, ZERO_POSITION, ""
634
        ),
635
    ) -> wx.Sizer:
636
        layout_label = wx.StaticText(self, -1, self._("Keyboard layout file:"))
1✔
637
        layout_picker = get_file_picker(
1✔
638
            self,
639
            -1,
640
            wildcard="JSON files (*.json)|*.json|All files (*)|*",
641
            style=wx.FLP_USE_TEXTCTRL,
642
        )
643
        layout_picker.SetMinSize((400, -1))
1✔
644
        if layout_path:
1✔
645
            layout_picker.SetPath(layout_path)
1✔
646
            layout_picker.GetTextCtrl().SetInsertionPointEnd()
1✔
647

648
        key_distance_x = LabeledTextCtrl(
1✔
649
            self,
650
            wx_("Step X:"),
651
            value=str(key_distance[0]),
652
            width=5,
653
            validator=FloatValidator(),
654
        )
655
        key_distance_y = LabeledTextCtrl(
1✔
656
            self,
657
            wx_("Step Y:"),
658
            value=str(key_distance[1]),
659
            width=5,
660
            validator=FloatValidator(),
661
        )
662

663
        key_annotation = LabeledTextCtrl(
1✔
664
            self, wx_("Footprint Annotation") + ":", element_info.annotation_format
665
        )
666

667
        key_position = ElementPositionWidget(self, ZERO_POSITION, disable_offsets=True)
1✔
668
        if element_info.position:
1✔
669
            key_position.set_position(element_info.position)
1✔
670

671
        box = wx.StaticBox(self, label=self._("Switch settings"))
1✔
672
        sizer = wx.StaticBoxSizer(box, wx.VERTICAL)
1✔
673

674
        row1 = wx.BoxSizer(wx.HORIZONTAL)
1✔
675
        row1.Add(layout_label, 0, wx.LEFT | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 5)
1✔
676
        row1.Add(layout_picker, 1, wx.ALL, 5)
1✔
677
        row1.Add(key_distance_x, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5)
1✔
678
        row1.Add(key_distance_y, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5)
1✔
679
        sizer.Add(row1, 0, wx.EXPAND | wx.ALL, 5)
1✔
680

681
        row2 = wx.BoxSizer(wx.HORIZONTAL)
1✔
682
        row2.Add(key_annotation, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5)
1✔
683
        row2.Add(key_position, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5)
1✔
684
        sizer.Add(row2, 0, wx.EXPAND | wx.ALL, 5)
1✔
685

686
        self.__layout_picker = layout_picker
1✔
687
        self.__key_distance_x = key_distance_x.text
1✔
688
        self.__key_distance_y = key_distance_y.text
1✔
689
        self.__key_annotation_format = key_annotation.text
1✔
690
        self.__key_position = key_position
1✔
691

692
        return sizer
1✔
693

694
    def get_switch_diodes_section(
1✔
695
        self,
696
        enable: bool = True,
697
        route_switches_with_diodes: bool = True,
698
        optimize_diodes_orientation: bool = False,
699
        element_info: ElementInfo = ElementInfo(
700
            "D{}", PositionOption.DEFAULT, DEFAULT_DIODE_POSITION, ""
701
        ),
702
    ) -> wx.Sizer:
703
        place_diodes_checkbox = wx.CheckBox(self, label=wx_("Allow autoplacement"))
1✔
704
        place_diodes_checkbox.SetValue(enable)
1✔
705
        place_diodes_checkbox.Bind(wx.EVT_CHECKBOX, self.on_diode_place_checkbox)
1✔
706

707
        switches_and_diodes_tracks_checkbox = wx.CheckBox(
1✔
708
            self, label=self._("Route with switches")
709
        )
710
        switches_and_diodes_tracks_checkbox.SetValue(route_switches_with_diodes)
1✔
711

712
        optimize_diodes_orientation_checkbox = wx.CheckBox(
1✔
713
            self, label=self._("Automatically adjust orientation")
714
        )
715
        optimize_diodes_orientation_checkbox.SetValue(optimize_diodes_orientation)
1✔
716

717
        diode_settings = ElementSettingsWidget(
1✔
718
            self,
719
            element_info,
720
            default_position=DEFAULT_DIODE_POSITION,
721
        )
722

723
        box = wx.StaticBox(self, label=self._("Switch diodes settings"))
1✔
724
        sizer = wx.StaticBoxSizer(box, wx.VERTICAL)
1✔
725

726
        row1 = wx.BoxSizer(wx.HORIZONTAL)
1✔
727
        row1.Add(place_diodes_checkbox, 0, wx.ALL, 0)
1✔
728
        row1.Add(switches_and_diodes_tracks_checkbox, 0, wx.ALL, 0)
1✔
729
        row1.Add(optimize_diodes_orientation_checkbox, 0, wx.ALL, 0)
1✔
730

731
        sizer.Add(row1, 0, wx.EXPAND | wx.ALL, 5)
1✔
732
        # weird border value to make it aligned with 'additional_elements_section':
733
        sizer.Add(diode_settings, 0, wx.EXPAND | wx.ALL, 9)
1✔
734

735
        self.__place_diodes_checkbox = place_diodes_checkbox
1✔
736
        self.__switches_and_diodes_tracks_checkbox = switches_and_diodes_tracks_checkbox
1✔
737
        self.__optimize_diodes_orientation_checkbox = (
1✔
738
            optimize_diodes_orientation_checkbox
739
        )
740
        self.__diode_settings = diode_settings
1✔
741

742
        self.__enable_diode_settings(enable)
1✔
743

744
        return sizer
1✔
745

746
    def __enable_diode_settings(self, enable: bool) -> None:
1✔
747
        if enable:
1✔
748
            self.__diode_settings.Enable()
1✔
749
            self.__optimize_diodes_orientation_checkbox.Enable()
1✔
750
        else:
751
            self.__diode_settings.Disable()
1✔
752
            self.__optimize_diodes_orientation_checkbox.Disable()
1✔
753

754
    def on_diode_place_checkbox(self, event: wx.CommandEvent) -> None:
1✔
755
        is_checked = event.GetEventObject().IsChecked()
×
756
        self.__enable_diode_settings(is_checked)
×
757

758
    def get_additional_elements_section(
1✔
759
        self,
760
        elements_info: List[ElementInfo] = [
761
            ElementInfo("ST{}", PositionOption.CUSTOM, ZERO_POSITION, "")
762
        ],
763
    ) -> wx.Sizer:
764
        self.__additional_elements = []
1✔
765

766
        scrolled_window = wx.ScrolledWindow(self)
1✔
767
        scrolled_window_sizer = wx.BoxSizer(wx.VERTICAL)
1✔
768

769
        scrolled_window.SetSizer(scrolled_window_sizer)
1✔
770
        virtual_width, virtual_height = scrolled_window_sizer.GetMinSize()
1✔
771
        scrolled_window.SetVirtualSize((virtual_width, virtual_height))
1✔
772
        scrolled_window.SetScrollRate(0, 10)
1✔
773

774
        box = wx.StaticBox(self, label=self._("Additional elements settings"))
1✔
775
        sizer = wx.StaticBoxSizer(box, wx.VERTICAL)
1✔
776

777
        sizer.Add(scrolled_window, 1, wx.EXPAND | wx.ALL, 10)
1✔
778

779
        dialog_width, _ = self.GetSize()
1✔
780
        sizer.SetMinSize((dialog_width, 180))
1✔
781

782
        buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
1✔
783

784
        def add_element(element_info: ElementInfo) -> None:
1✔
785
            element_settings = ElementSettingsWidget(scrolled_window, element_info)
1✔
786
            self.__additional_elements.append(element_settings)
1✔
787
            scrolled_window_sizer.Add(element_settings, 0, wx.EXPAND | wx.ALIGN_LEFT, 0)
1✔
788
            self.GetTopLevelParent().Layout()
1✔
789

790
        def add_element_callback(_) -> None:
1✔
791
            add_element(ElementInfo("", PositionOption.CUSTOM, ZERO_POSITION, ""))
×
792

793
        for element_info in elements_info:
1✔
794
            add_element(element_info)
1✔
795

796
        add_icon = PyEmbeddedImage(
1✔
797
            b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAACtJ"
798
            b"REFUOI1jYKAx+A/FOAETpTaMGsDAwAil8YY0Pv0Uu4AQGE0H9DCAYgAADfAFFDV6vY8AAAAA"
799
            b"SUVORK5CYII="
800
        ).GetBitmap()
801
        add_button = wx.BitmapButton(self, bitmap=add_icon)
1✔
802
        add_button.Bind(wx.EVT_BUTTON, add_element_callback)
1✔
803

804
        def remove_element(_) -> None:
1✔
805
            element_settings = (
×
806
                self.__additional_elements.pop() if self.__additional_elements else None
807
            )
808
            if element_settings:
×
809
                element_settings.Destroy()
×
810
                self.Layout()
×
811

812
        remove_icon = PyEmbeddedImage(
1✔
813
            b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAABtJ"
814
            b"REFUOI1jYBgFwwAwQun/5OpnopZLRsGQBgBLTwEEpzJYVwAAAABJRU5ErkJggg=="
815
        ).GetBitmap()
816
        remove_button = wx.BitmapButton(self, bitmap=remove_icon)
1✔
817
        remove_button.Bind(wx.EVT_BUTTON, remove_element)
1✔
818

819
        buttons_sizer.Add(add_button, 0, wx.EXPAND | wx.ALL, 0)
1✔
820
        buttons_sizer.Add(remove_button, 0, wx.EXPAND | wx.ALL, 0)
1✔
821

822
        sizer.Add(buttons_sizer, 0, wx.EXPAND | wx.ALL, 5)
1✔
823

824
        return sizer
1✔
825

826
    def get_misc_section(
1✔
827
        self,
828
        route_rows_and_columns: bool = True,
829
        template_path: str = "",
830
        generate_outline: bool = False,
831
        outline_delta: float = 0.0,
832
    ) -> wx.Sizer:
833
        row_and_columns_tracks_checkbox = wx.CheckBox(
1✔
834
            self, label=self._("Route rows and columns")
835
        )
836
        row_and_columns_tracks_checkbox.SetValue(route_rows_and_columns)
1✔
837

838
        template_label = wx.StaticText(
1✔
839
            self, -1, self._("Controller circuit template file:")
840
        )
841
        template_picker = get_file_picker(
1✔
842
            self,
843
            -1,
844
            wildcard="KiCad printed circuit board files (*.kicad_pcb)|*.kicad_pcb",
845
            style=wx.FLP_USE_TEXTCTRL,
846
        )
847
        if template_path:
1✔
848
            template_picker.SetPath(template_path)
1✔
849
            template_picker.GetTextCtrl().SetInsertionPointEnd()
1✔
850

851
        row1 = wx.BoxSizer(wx.HORIZONTAL)
1✔
852
        row1.Add(row_and_columns_tracks_checkbox, 0, wx.EXPAND | wx.ALL, 5)
1✔
853
        row1.Add(wx.StaticLine(self, style=wx.LI_VERTICAL), 0, wx.EXPAND | wx.ALL, 5)
1✔
854
        row1.Add(template_label, 0, wx.LEFT | wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 5)
1✔
855
        row1.Add(template_picker, 1, wx.EXPAND | wx.ALL, 5)
1✔
856

857
        generate_outline_checkbox = wx.CheckBox(
1✔
858
            self, label=self._("Build board outline")
859
        )
860
        generate_outline_checkbox.SetValue(generate_outline)
1✔
861
        generate_outline_checkbox.Bind(
1✔
862
            wx.EVT_CHECKBOX, self.on_generate_outline_checkbox
863
        )
864
        outline_delta_ctrl = LabeledTextCtrl(
1✔
865
            self,
866
            self._("Outline delta:"),
867
            value=str(outline_delta),
868
            width=5,
869
            validator=FloatValidator(),
870
        )
871

872
        row2 = wx.BoxSizer(wx.HORIZONTAL)
1✔
873
        row2.Add(generate_outline_checkbox, 0, wx.EXPAND | wx.ALL, 5)
1✔
874
        row2.Add(wx.StaticLine(self, style=wx.LI_VERTICAL), 0, wx.EXPAND | wx.ALL, 5)
1✔
875
        row2.Add(outline_delta_ctrl, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 5)
1✔
876

877
        box = wx.StaticBox(self, label=self._("Other settings"))
1✔
878
        sizer = wx.StaticBoxSizer(box, wx.VERTICAL)
1✔
879
        sizer.Add(row1, 0, wx.EXPAND | wx.ALL, 5)
1✔
880
        sizer.Add(row2, 0, wx.EXPAND | wx.ALL, 5)
1✔
881

882
        self.__rows_and_columns_tracks_checkbox = row_and_columns_tracks_checkbox
1✔
883
        self.__template_picker = template_picker
1✔
884
        self.__generate_outline_checkbox = generate_outline_checkbox
1✔
885
        self.__outline_delta_ctrl = outline_delta_ctrl
1✔
886

887
        self.__enable_outline_delta(generate_outline)
1✔
888

889
        return sizer
1✔
890

891
    def __enable_outline_delta(self, enable: bool) -> None:
1✔
892
        if enable:
1✔
893
            self.__outline_delta_ctrl.Enable()
1✔
894
        else:
895
            self.__outline_delta_ctrl.Disable()
1✔
896

897
    def on_generate_outline_checkbox(self, event: wx.CommandEvent) -> None:
1✔
898
        is_checked = event.GetEventObject().IsChecked()
×
899
        self.__enable_outline_delta(is_checked)
×
900

901
    def on_help_button(self, event: wx.CommandEvent) -> None:
1✔
902
        del event
×
903
        help_dialog = HelpDialog(self)
×
904
        help_dialog.ShowModal()
×
905
        help_dialog.Destroy()
×
906

907
    def get_layout_path(self) -> str:
1✔
908
        return self.__layout_picker.GetPath()
1✔
909

910
    def get_key_annotation_format(self) -> str:
1✔
911
        return self.__key_annotation_format.GetValue()
1✔
912

913
    def route_switches_with_diodes(self) -> bool:
1✔
914
        return self.__switches_and_diodes_tracks_checkbox.GetValue()
1✔
915

916
    def optimize_diodes_orientation(self) -> bool:
1✔
917
        return self.__optimize_diodes_orientation_checkbox.GetValue()
1✔
918

919
    def route_rows_and_columns(self) -> bool:
1✔
920
        return self.__rows_and_columns_tracks_checkbox.GetValue()
1✔
921

922
    def get_key_distance(self) -> Tuple[float, float]:
1✔
923
        x = float(self.__key_distance_x.GetValue())
1✔
924
        y = float(self.__key_distance_y.GetValue())
1✔
925
        return x, y
1✔
926

927
    def get_template_path(self) -> str:
1✔
928
        return self.__template_picker.GetPath()
1✔
929

930
    def get_key_info(self) -> ElementInfo:
1✔
931
        return ElementInfo(
1✔
932
            self.get_key_annotation_format(),
933
            PositionOption.DEFAULT,
934
            self.__key_position.GetValue(),
935
            "",
936
        )
937

938
    def enable_diode_placement(self) -> bool:
1✔
939
        return self.__place_diodes_checkbox.GetValue()
1✔
940

941
    def get_diode_info(self) -> ElementInfo:
1✔
942
        return self.__diode_settings.GetValue()
1✔
943

944
    def get_additional_elements_info(self) -> List[ElementInfo]:
1✔
945
        return [e.GetValue() for e in self.__additional_elements]
1✔
946

947
    def generate_outline(self) -> bool:
1✔
948
        return self.__generate_outline_checkbox.GetValue()
1✔
949

950
    def get_outline_delta(self) -> float:
1✔
951
        return float(self.__outline_delta_ctrl.text.GetValue())
1✔
952

953
    def get_window_state(self) -> WindowState:
1✔
954
        return WindowState(
1✔
955
            layout_path=self.get_layout_path(),
956
            key_distance=self.get_key_distance(),
957
            key_info=self.get_key_info(),
958
            enable_diode_placement=self.enable_diode_placement(),
959
            route_switches_with_diodes=self.route_switches_with_diodes(),
960
            optimize_diodes_orientation=self.optimize_diodes_orientation(),
961
            diode_info=self.get_diode_info(),
962
            additional_elements=self.get_additional_elements_info(),
963
            route_rows_and_columns=self.route_rows_and_columns(),
964
            template_path=self.get_template_path(),
965
            generate_outline=self.generate_outline(),
966
            outline_delta=self.get_outline_delta(),
967
        )
968

969

970
def load_window_state_from_log(filepath: str) -> WindowState:
1✔
971
    try:
1✔
972
        with open(filepath, "r") as f:
1✔
973
            for line in f:
1✔
974
                if "GUI state:" in line:
1✔
975
                    state = WindowState.from_dict(json.loads(line[line.find("{") :]))
1✔
976
                    logger.info("Using window state found in previous log")
1✔
977
                    return state
1✔
978
    except Exception:
1✔
979
        # if something went wrong use default
980
        pass
1✔
981
    logger.warning("Failed to parse window state from log file, using default")
1✔
982
    return WindowState()
1✔
983

984

985
# used for tests
986
if __name__ == "__main__":
1✔
987
    import argparse
1✔
988
    import threading
1✔
989

990
    from .kbplacer_plugin import run_from_gui
1✔
991

992
    parser = argparse.ArgumentParser(description="dialog test")
1✔
993
    parser.add_argument(
1✔
994
        "-i", "--initial-state-file", default="", help="Initial gui state file"
995
    )
996
    parser.add_argument(
1✔
997
        "-o", "--output-dir", required=True, help="Directory for output files"
998
    )
999
    parser.add_argument(
1✔
1000
        "--run-without-dialog",
1001
        required=False,
1002
        action="store_true",
1003
        help="Run with loaded state without displaying dialog window",
1004
    )
1005
    parser.add_argument(
1✔
1006
        "-b",
1007
        "--board",
1008
        required=False,
1009
        default="",
1010
        help=".kicad_pcb file to be used with --run-without-dialog option",
1011
    )
1012
    args = parser.parse_args()
1✔
1013

1014
    initial_state = load_window_state_from_log(args.initial_state_file)
1✔
1015
    if not args.run_without_dialog:
1✔
1016
        app = wx.App()
1✔
1017
        dlg = KbplacerDialog(None, "kbplacer", initial_state=initial_state)
1✔
1018

1019
        if "PYTEST_CURRENT_TEST" in os.environ:
1✔
1020
            print(f"Using {wx.version()}")
1✔
1021

1022
            # use stdin for gracefully closing GUI when running
1023
            # from pytest. This is required when measuring
1024
            # coverage and process kill would cause measurement to be lost
1025
            def listen_for_exit() -> None:
1✔
1026
                input("Press any key to exit: ")
1✔
1027
                dlg.Close()
1✔
1028
                wx.Exit()
1✔
1029

1030
            input_thread = threading.Thread(target=listen_for_exit)
1✔
1031
            input_thread.start()
1✔
1032

1033
            dlg.Show()
1✔
1034
            app.MainLoop()
1✔
1035
        else:
1036
            dlg.ShowModal()
×
1037

1038
        with open(f"{args.output_dir}/window_state.json", "w") as f:
1✔
1039
            f.write(f"{dlg.get_window_state()}")
1✔
1040

1041
        print("exit ok")
1✔
1042
    else:
1043
        import pcbnew
1✔
1044

1045
        board = run_from_gui(args.board, initial_state)
1✔
1046
        pcbnew.Refresh()
1✔
1047
        pcbnew.SaveBoard(args.board, board)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc