-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathpygterm64.py
3148 lines (2588 loc) · 137 KB
/
pygterm64.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
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
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# thoughts about modes:
# - sizes:
# - fixed size
# - specify two of these three: font size/half-height; term size; window size
# - resizable
# - spec like fixed size
# - resizing will adjust the number of cells; full-screen is just one more size
# - full-screen
# - user specifies either term size or font size
# - terminal can be
# - bottom-less (TEXT)
# - just the visible portion (GRAPHICS)
# - graphics
# - user can draw into the terminal, but scrolling will remove things
# - requires graphcis mode (??), i.e. no longer bottom-less
# - but how about sprites? Can they float virtually?
# - Ctrl-S *hides* output Ctrl-Q resumes it--simple double-buffering! (these codes are used unfortunately; Esc-GRAPHICS: freeze?)
# - should font size honor high-DPI settings?
# unsolved issues:
# - Ctrl-C: how to generate that, to interrupt the Python interpreter (incl our commands such as 'list')
# - fg thread should be able to use the event queue; but we need a bg thread to poll for Ctrl-C
# - have a Python program use pygame as well, without conflict; ideally in our window
# - if they open pygame, then they get their own, as normal
# - we should allow to use ours though, with a simpler API
import copy
import time
import sys
import textwrap
import pygame
from pygame.locals import *
from typing import Tuple, List
"""
Some nomenclature in this module's comments explained:
Cells:
The space for each character is called a cell in this module. Cells are all of an identical size, which is based on the font being used. (only a single font of a single size can be used in a PygcurseSurface object. Cell coordinates refer to the positions of characters on the surface. Pixel coordinates refer to the position of each pixel.
Scrolling:
The term "scrolling" refers to when a character is printed at the bottom right corner, which causes all the characters on the surface to be moved up and a blank row to be created at the bottom. The print() and write() functions causes scolls if it prints enough characters. The putchar() and putchars() functions do not.
Color parameters:
Several Pygcurse functions take colors for their parameters. These can almost always (there might be some exceptions) be:
1) A pygame.Color object.
2) An RGB tuple of three integers, 0 to 255 (like Pygame uses)
3) An RGBA tuple of four integers, 0 to 255 (like Pygame uses)
4) A string such as 'blue', 'lime', or 'gray' (or any of the strings listed in the colornames gloal dictionary. This dict can be updated with more colors if the user wants.)
5) None, which means use whatever color the cell already uses.
Region parameters:
A "region" defines an area of the surface. It can be the following formats:
1) Four-integer tuple (x, y, width, height)
2) Four-integer tuple (x, y, None, None) which means x,y and extending to the right & bottom edge of the surface
3) None or (None, None, None, None) which means the entire surface
4) pygame.Rect object
Note about flickering: If your program is experiencing a lot of flicker, than you should disable the self._autoupdate member. By default, this is enabled and the screen is redrawn after each method call that makes a change to the screen.
"""
# Internally used constants:
_NEW_WINDOW = 'new_window'
FULLSCREEN = 'full_screen'
# Directional constants:
NORTH = 'N'
EAST = 'E'
SOUTH = 'S'
WEST = 'W'
NORTHEAST = 'NE'
NORTHWEST = 'NW'
SOUTHEAST = 'SE'
SOUTHWEST = 'SW'
# A mapping of strings to color objects.
colornames = {'white': pygame.Color(255, 255, 255),
'yellow': pygame.Color(255, 255, 0),
'fuchsia': pygame.Color(255, 0, 255),
'red': pygame.Color(255, 0, 0),
'silver': pygame.Color(192, 192, 192),
'gray': pygame.Color(128, 128, 128),
'olive': pygame.Color(128, 128, 0),
'purple': pygame.Color(128, 0, 128),
'maroon': pygame.Color(128, 0, 0),
'aqua': pygame.Color( 0, 255, 255),
'lime': pygame.Color( 0, 255, 0),
'teal': pygame.Color( 0, 128, 128),
'green': pygame.Color( 0, 128, 0),
'blue': pygame.Color( 0, 0, 255),
'navy': pygame.Color( 0, 0, 128),
'black': pygame.Color( 0, 0, 0)}
C64_COLORS = {
chr(144): pygame.Color( 0, 0, 0), # black
chr( 5): pygame.Color(255, 255, 255), # white
chr( 28): pygame.Color(104, 55, 43), # red \x1c
chr(159): pygame.Color(112, 164, 178), # cyan \x9f
chr(156): pygame.Color(111, 61, 134), # purple \x9c
chr( 30): pygame.Color( 88, 141, 67), # green \x1e
chr( 31): pygame.Color( 53, 40, 121), # blue \x1f
chr(158): pygame.Color(184, 199, 111), # violet \x9e --yellow???
chr(129): pygame.Color(111, 79, 37), # orange \x81
chr(149): pygame.Color( 67, 57, 0), # brown
chr(150): pygame.Color(154, 103, 89), # lt. red \x96
chr(151): pygame.Color( 68, 68, 68), # gray 1
chr(152): pygame.Color(108, 108, 108), # gray 2
chr(153): pygame.Color(154, 210, 132), # lt. green \x99
chr(154): pygame.Color(108, 94, 181), # lt. blue
chr(155): pygame.Color(149, 149, 149) # gray 3
}
C64_EXTRA_CONTROL_KEYS = {
'1': chr(144), # Ctrl-digit
'2': chr( 5),
'3': chr( 28),
'4': chr(159),
'5': chr(156),
'6': chr( 30),
'7': chr( 31),
'8': chr(158),
'!': chr(129), # Commodore-digit, we use Shift
'@': chr(149),
'#': chr(150),
'$': chr(151),
'%': chr(152),
'^': chr(153),
'&': chr(154),
'*': chr(155),
'9': '\x12', # reverse
'0': '\x92' # reverse off
}
C64_INV_CHARS = { # rev-video representations of various C64 control characters
# colors
chr(144): '1', # Ctrl-digit
chr( 5): '2',
chr( 28): '3',
chr(159): '4',
chr(156): '5',
chr( 30): '6',
chr( 31): '7',
chr(158): '8',
chr(129): '!', # Commodore-digit, we use Shift
chr(149): '@',
chr(150): '#',
chr(151): '$',
chr(152): '%',
chr(153): '^',
chr(154): '&',
chr(155): '*',
# rev video
'\x12': '9', # reverse
'\x92': '0', # reverse off
# home/clear
'\x13': 'H', # home (21f1: nice home symbol; not available in Consolas)
'\x93': 'C', # clear screen
# cursor keys
'\x9d': '\u2190', # cursor left
'\x1d': '\u2192', # cursor right
'\x91': '\u2191', # cursor up
'\x11': '\u2193', # cursor down
# other
'\x14': 'D', # delete (delete-right symbol 2326 not available
'\x94': 'I', # insert
'\x09': 'T', # TAB
# 21a9: Enter key (0x8d)
}
US_ASCII_SHIFT_MAP = {
'`': '~',
'1': '!',
'2': '@',
'3': '#',
'4': '$',
'5': '%',
'6': '^',
'7': '&',
'8': '*',
'9': '(',
'0': ')',
'-': '_',
'=': '+',
'[': '{',
']': '}',
'\\': '|',
';': ':',
"'": '"',
',': '<',
'.': '>',
'/': '?'
}
DEFAULTFGCOLOR = C64_COLORS['\x9a'] # default foreground color is gray (must be a pygame.Color object)
DEFAULTBGCOLOR = C64_COLORS['\x1f'] # default background color is black (must be a pygame.Color object)
ERASECOLOR = pygame.Color(0, 0, 0, 0) # erase color has 0 alpha level (must be a pygame.Color object)
class PygcurseTerminalSurface(object):
"""
A PygcurseSurface object is the ascii-based analog of Pygame's Surface objects. It represents a 2D field of ascii characters, exactly like a console terminal. Each cell can have a different character, foreground color, background color, and RGB tint. The PygcurseSurface object also tracks the location of the cursor (where the print() and putchar() functions will output text) and the "input cursor" (the blinking cursor when the user is typing in characters.)
Each xy position on the surface is called a "cell". A cell can hold one and only one character.
The PygcurseSurface object contains a pygame.Surface object that it draws to. This pygame.Surface object in turn may have additional Pygame drawing functions called on it before being drawn to the screen with pygame.display.update().
It should be noted that none of the code in the pygcurse module should at all be considered thread-safe.
"""
_pygcurseClass = 'PygcurseTerminalSurface'
# nomenclature:
# - buffer: the logical entity with wrapping lines (logical row/col coordinates)
# - terminal: the viewing rectangle, the content of the window (char-celll coordinates)
# - window: the OS window (pixel coordinates)
# wrapping behavior --note: must be consistently done this way
# - text wraps at width, but cursor position is ON the margin when on end
# - e.g. width=40
# - 1 row: 0..40
# - 2 rows: 41..80 notice the asymmetry of length 0
# - 3 rows: 81..120
def _members(self):
# editor state
#self._content : List[List[Tuple[str, Tuple[], bool]] # content of logical buffer, bottomless (mostly)
# guaranteed to exist up to (but not including) _crsr_loc
self._crsr_loc : Tuple[int, int] # cursor position in logical buffer (lines can be longer than screen width)
# _crsr_loc gets moved by printing, and also by the screen editor.
# It may sit exactly on terminal border (col == _term_size[0]);
# in this case, it will wrap once written to.
self._term_loc : Tuple[int, int] # cursor position on terminal screen (kept in sync with _crsr_loc)
self._term_size : Tuple[int, int] # current terminal width and height in chars
self._term_top_loc : Tuple[int, int] # buffer location of first visible row/col (col != 0 for over-wide lines)
self._dirty : int = 0 # this many chars left of cursor pos have not been drawn yet, and have uniform characteristics
self._cursor_visible : bool = False # true if cursor is visible
self._insert_printable = False # flag for editor that printable chars should be inserted on-screen
# window
self._cell_size : Tuple[int, int] # size of a character in pixels
self._wndw_size : Tuple[int, int] # window size in pixels
self._windowsurface : pygame.Surface # ...correct?
self._term_surface : pygame.Surface # ...what is this?
self._pending_events : List[pygame.event] = []
self._cursor_buffer : pygame.Surface # pixels under cursor are stored here; valid if _cursor_visible
# modifyable user options
self._font : pygame.Font
self._current_colors : Tuple[pygame.Color, pygame.Color] # current fg and bg color
self._current_colors_reversed : Tuple[pygame.Color, pygame.Color] # current fg and bg color
self._rev_mode : bool = False
self._current_colors_or_reversed : Tuple[pygame.Color, pygame.Color] # current fg and bg color reflecting _rev_mode
self._tabsize : int = 8 # how many spaces a tab inserts.
self._autoupdate : bool = True
# modifyable user state
self._input_mode : int = 0 # -1: screen editor; 0..: constrained with #chars of prompt; None: raw
self._escString : str = None
# immutable user options (passed to or determined in constructor)
self._half_height : bool = True
self._managesdisplay : bool
self._autodisplayupdate : bool
self._autoblit : bool
def _update_colors_or_reversed(self):
self._current_colors_or_reversed = self._current_colors if not self._rev_mode else \
self._current_colors_reversed
def _set_colors(self, colors : Tuple[pygame.Color, pygame.Color]):
self._current_colors = colors
self._current_colors_reversed = (colors[1], colors[0])
self._update_colors_or_reversed()
def _set_rev_mode(self, rev_mode : bool):
self._rev_mode = rev_mode
self._update_colors_or_reversed()
# invalidate character left of current location; render if going beyond one line
def _add_to_dirty(self, n : int):
self._dirty += n
def _flush_dirty(self):
if self._dirty:
current_row = self._content[self._crsr_loc[1]]
# now draw s to left of _term_loc. If s spans multiple lines, do multiple draws
crsr_x = self._crsr_loc[0]
term_x = self._term_loc[0]
term_y = self._term_loc[1]
# the dirty section may span multiple terminal lines, which we will draw one by one
while self._dirty > 0:
term_y = self._lazy_scroll(term_y) # make sure this row is visible
n = (self._dirty - 1) % self._term_size[0] + 1
term_x -= n
crsr_x -= n
dirty_cells = current_row[crsr_x : crsr_x + n]
s = "".join(cell_tuple[0] for cell_tuple in dirty_cells)
colors = current_row[crsr_x][1]
self._draw_text((term_x, term_y), colors, s)
self._dirty -= n
term_y -= 1 # in case string is longer, position new end at right of screen one row up
term_x = self._term_size[0]
def _draw_text(self, term_loc : int, colors, s : str):
if s[0] in C64_INV_CHARS: # control chars such as color-change are rendered in reverse
assert len(s) == 1
s = C64_INV_CHARS[s]
x = self._cell_size[0] * term_loc[0]
y = self._cell_size[1] * term_loc[1]
cell_rect = pygame.Rect(x, y, self._cell_size[0] * len(s), self._cell_size[1])
# render the character and draw it to the surface
char_surf = self._font.render(s, 1, colors[0], colors[1])
tgt_rect = char_surf.get_rect()
if self._half_height:
char_surf = pygame.transform.scale(char_surf, (tgt_rect.width, tgt_rect.height // 2))
tgt_rect = char_surf.get_rect()
tgt_rect.topleft = (x, y)
# @TODO: try .bottom, maybe it works better re rounding for small fonts
if cell_rect != tgt_rect:
self._term_surface.fill(colors[1], cell_rect)
self._term_surface.blit(char_surf, tgt_rect)
# @BUGBUG: seems rendered chars are 1 pixel narrower
# if terminal cursor has moved out of the screen, scroll to bring it back
def _lazy_scroll(self, term_y : int):
scroll = 0
if term_y < 0:
scroll = term_y # negative, means scroll down
elif term_y >= self._term_size[1]: # _term_loc[1] always becomes visible
scroll = term_y - self._term_size[1] + 1
if scroll != 0:
self._scroll_vert(scroll)
return term_y - scroll
def _redraw_current_line(self):
rel_loc = self._current_rel_term_loc()
term_y0 = self._term_loc[1] - rel_loc[1]
term_y1 = term_y0 + rel_loc[2]
self._redraw_term_rows((term_y0, term_y1))
def _redraw_screen(self):
self._redraw_term_rows((0, self._term_size[1]))
self._blit_to_screen()
def _redraw_term_rows(self, term_y_range : Tuple[int, int]):
self._show_cursor(False)
# loop over all term rows and redraw those that intersect
crsr_loc = self._term_top_loc
w = self._term_size[0]
for y in range(self._term_size[1]):
if y >= term_y_range[1]:
break
has_line = crsr_loc[1] < len(self._content)
if has_line:
line = self._content[crsr_loc[1]]
rel_loc = self._rel_term_loc(crsr_loc, line)
if y >= term_y_range[0]:
if has_line:
x0 = rel_loc[1] * w
# redraw from crsr_loc to end of line
x1 = x0 + w
if x1 > len(line):
x1 = len(line)
for x in range(x0,x1):
cell = line[x]
self._draw_text((x - x0,y), cell[1], cell[0])
# this function is called after a resize; take chance to fix up term_loc
if crsr_loc[1] == self._crsr_loc[1] and self._crsr_loc[0] >= x0 and self._crsr_loc[0] <= x1:
self._term_loc = (self._crsr_loc[0] - x0, y)
else:
x0 = 0
x1 = 0
# clear the rest
if x1 < w:
bg_rect = (self._cell_size[0] * x1, self._cell_size[1] * y, self._cell_size[0] * (w - x1), self._cell_size[1])
self._term_surface.fill(self._current_colors[1], bg_rect)
if has_line: # advance one term row in crsr space
if rel_loc[1] == rel_loc[2] - 1: # in last row of line: next line
crsr_loc = (0, crsr_loc[1] + 1)
else: # otherwise step through line by term width
crsr_loc = (crsr_loc[0] + w, crsr_loc[1])
# shift (=scroll) up the terminal window into the buffer by 'rows' (which may be negative -> shift down)
def _scroll_vert(self, rows):
# scroll visually
self._term_surface.scroll(0, -rows * self._cell_size[1])
term_y0 = 0 if rows < 0 else \
self._term_size[1] - rows
term_y1 = term_y0 + abs(rows)
# adjust the terminal location
self._term_loc = (self._term_loc[0], self._term_loc[1] - rows)
# adjust the top location
while rows > 0: # scrolling up
self._term_top_loc = (self._term_top_loc[0] + self._term_size[0], self._term_top_loc[1])
if self._term_top_loc[0] >= len(self._content[self._term_top_loc[1]]): # hit end
self._term_top_loc = (0, self._term_top_loc[1] + 1) # top is start of next logical line
rows -= 1
while rows < 0: # scrolling down
self._term_top_loc = (self._term_top_loc[0] - self._term_size[0], self._term_top_loc[1])
if self._term_top_loc[0] < 0: # ran beyond start
self._term_top_loc = ((self._current_rel_term_loc()[2] - 1) * self._term_size[1],
self._term_top_loc[1] - 1)
rows += 1
# redraw the newlty exposed rows (needed to get all state in sync first)
self._redraw_term_rows((term_y0, term_y1))
# make sure cursor location exists in content
def _pad_rows(self):
missing_rows = 1 + self._crsr_loc[1] - len(self._content)
if missing_rows > 0:
self._content += [[]] * missing_rows # make current row itself exist as well
# @TODO: if it gets too long, then drop lines from start (need to adjust _crsr_loc[1] and _term_first_loc[1])
def _pad_current_row(self):
current_row = self._content[self._crsr_loc[1]]
missing_cols = self._crsr_loc[0] - len(current_row)
if missing_cols > 0:
current_row += ((' ', self._current_colors_or_reversed, True),) * missing_cols # do NOT make current col exist
# move cursor to a completely different location
def _move_term_loc_to(self, term_loc):
pass
# move cursor to an on-screen character cell
# Notes:
# - cell may be inside a wrapped line; must handle that
# - location may be off-screen; this forces a scroll
def goto_xy(self, loc):
# @TODO: I think this line is nonsense
self._move_term_loc_to((loc[0] + self._term_top_loc[0], loc[1] + self._term_top_loc[1]))
# determine relative terminal coordinates within current line (used e.g. when moving the cursor)
# returns (rel term_x, rel term_y, total number of term rows)
# @TODO: rel term_x == _crsr_loc[0] i.e. redundant; maybe we can remove it
def _current_rel_term_loc(self):
return self._rel_term_loc(self._crsr_loc, self._current_line())
def _rel_term_loc(self, crsr_loc : Tuple[int, int], current_line : list):
res = (crsr_loc[0] % self._term_size[0],
crsr_loc[0] // self._term_size[0],
self._num_term_rows(current_line)
)
# if we are at end, we wrap back onto the margin
if res[1] == res[2]:
res = (self._term_size[0], res[2] - 1, res[2])
return res
def _num_term_rows(self, current_line : list): # how many terminal rows does 'line' occupy?
return 1 if not current_line else \
(len(current_line) - 1) // self._term_size[0] + 1
# write string to terminal
def write(self, s: str):
self._show_cursor(False)
for c in s:
if self._escString is not None: # we are collecting an ESC sequence
if c in C64_INV_CHARS.keys(): # ESC followed by C64 control char: print letter in reverse
self._escString = None # back to normal mode
self._flush_dirty()
self._write_printable(c, self._current_colors_reversed) # render as printable chars in rev colors
self._flush_dirty() # color boundary
elif c == '\x07': # ESC]0;window titleBEL
if self._escString.startswith(']0;'): # set window title
pygame.display.set_caption(self._escString[3:])
self._escString = None
elif c < ' ' or (c >= '\x80' and c <= '\x9f'):
self._escString = None # weird: ignore
else:
self._escString += c # accumulate
elif c == '\x1b': # ESC
self._escString = "" # this arms ESC
elif c == '\x01': # (non-C64)
self._flush_dirty()
rel_row = self._current_rel_term_loc()[1] # relative row in current line
self._crsr_loc = (0, self._crsr_loc[1])
self._term_loc = (0, self._term_loc[1] - rel_row)
self._lazy_scroll(self._term_loc[1])
elif c == '\x8d' or c == '\n':
self._flush_dirty()
rel_loc = self._current_rel_term_loc()
self._crsr_loc = (0, self._crsr_loc[1] + 1)
self._term_loc = (0, self._term_loc[1] - rel_loc[1] + rel_loc[2])
self._pad_rows()
self._lazy_scroll(self._term_loc[1])
elif c == '\t':
add_cols = (self._crsr_loc[0] + self._tabsize) // self._tabsize * self._tabsize - self._crsr_loc[0]
# @TODO: didn't the C64 have a code to set a TAB stop?
for i in range(add_cols):
current_line = self._current_line()
if self._crsr_loc[0] < len(current_line) and not self._insert_printable:
self.write('\x1d') # within line: cursor-right over it
else:
self.write(' ') # at end: add spaces
elif c == '\x93' or c == chr(12): # clear screen -@TODO: does f120 clear the screen?
self.clr_scr()
elif c == '\x13': # go to home position without clear --@TODO or is this goto_xy((0,0)) without scrolling?
self.home()
elif c == '\x12':
self._flush_dirty()
self._set_rev_mode(True)
elif c == '\x92':
self._flush_dirty()
self._set_rev_mode(False)
elif c == '\x14': # DELETE
self._flush_dirty()
current_line = self._current_line()
if self._crsr_loc[0] < len(current_line):
del current_line[self._crsr_loc[0]]
# pad with a space, in case it gets a line shorter
# @TODO: use a special char that we can reliably strip when returning the line
current_line.append((' ', self._current_colors, True))
self._redraw_current_line()
elif c == '\x94':
self._flush_dirty()
current_line = self._current_line()
if self._crsr_loc[0] <= len(current_line):
current_line.insert(self._crsr_loc[0], (' ', self._current_colors, True))
# @BUGBUG: if it gets longer than the screen, we must insert a blank line
self._redraw_current_line()
elif c == '\x9d' or c == chr(8): # backspace (non-deleting)
self._escString = None # newline terminates open Esc
self._flush_dirty()
if self._crsr_loc != (0,0):
rel_loc = self._current_rel_term_loc()
term_loc0 = (self._term_loc[0] - rel_loc[0], # find term loc of char 0
self._term_loc[1] - rel_loc[1])
assert term_loc0[0] == 0
# move logical position back
self._crsr_loc = (self._crsr_loc[0] - 1, self._crsr_loc[1])
if self._crsr_loc[0] < 0: # wrapped back to end of previous line
# @BUGBUG: if line goes to the margin, we won't see the cursor
# workaround: just back up by one
self._crsr_loc = (len(self._content[self._crsr_loc[1] - 1]), self._crsr_loc[1] - 1)
rel_loc = self._current_rel_term_loc()
term_loc0 = (term_loc0[0], term_loc0[1] - rel_loc[2])
else:
rel_loc = self._current_rel_term_loc()
# recompute terminal location
self._term_loc = (term_loc0[0] + rel_loc[0],
term_loc0[1] + rel_loc[1])
# workaround: back up one more if cursor is invisible
if self._term_loc[0] == self._term_size[0]:
self._crsr_loc = (self._crsr_loc[0] - 1, self._crsr_loc[1])
self._term_loc = (self._term_loc[0] - 1, self._term_loc[1])
elif c == '\x1d': # cursor right
self._flush_dirty()
if self._crsr_loc[1] < len(self._content) - 1 or \
self._crsr_loc[0] < len(self._content[self._crsr_loc[1]]):
if self._crsr_loc[0] == len(self._current_line()):
self.write('\n')
else:
self._advance_right()
elif c == '\x11': # cursor down
self._flush_dirty()
if self._crsr_loc[1] < len(self._content) - 1 or \
len(self._content[self._crsr_loc[1]]) > 0:
self._crsr_loc = (self._crsr_loc[0] + self._term_size[0], self._crsr_loc[1]) # advance by screen width
self._term_loc = (self._term_loc[0], self._term_loc[1] + 1)
if self._crsr_loc[0] > len(self._current_line()): # beyond end of logical line
self._crsr_loc = (self._term_loc[0], self._crsr_loc[1] + 1) # wrap to next logical line
self._pad_rows()
if self._crsr_loc[0] > len(self._current_line()):
self._crsr_loc = (len(self._current_line()), self._crsr_loc[1])
self._term_loc = (self._current_rel_term_loc()[0], self._term_loc[1])
elif c == '\x91': # cursor up
self._flush_dirty()
if self._crsr_loc[1] > 0 or \
self._crsr_loc[0] > self._term_size[0]:
self._crsr_loc = (self._crsr_loc[0] - self._term_size[0], self._crsr_loc[1]) # move by screen width
self._term_loc = (self._term_loc[0], self._term_loc[1] - 1)
if self._crsr_loc[0] < 0:
num_rows = self._num_term_rows(self._content[self._crsr_loc[1] - 1])
self._crsr_loc = ((num_rows - 1) * self._term_size[0] + self._term_loc[0], self._crsr_loc[1] - 1) # wrap to prev logical line
if self._crsr_loc[0] > len(self._current_line()):
self._crsr_loc = (len(self._current_line()), self._crsr_loc[1])
self._term_loc = (self._current_rel_term_loc()[0], self._term_loc[1])
pass
elif c == '\x04': # Ctrl-D misused as END (not C64)
while self._crsr_loc[0] < len(self._current_line()):
self.write('\x1d') # arrow right
elif c == '\x84': # END of buffer (not C64)
while self._crsr_loc[1] < len(self._content) - 1:
self.write('\x0f') # page down
elif c == '\x0f' : # page down
num_down = self._term_size[1] - 1 - self._term_loc[1] # left to end
if num_down == 0: # at bottom: whole page
num_down = self._term_size[1] - 1
for i in range(num_down):
self.write('\x11') # cursor down --@TODO: disable display update for these individual ones
elif c == '\x8f' : # page up
num_up = self._term_loc[1]
if num_up == 0:
num_up = self._term_size[1] - 1
for i in range(num_up):
self.write('\x91') # @TODO: disable display update for these individual ones
elif c == '\x0e' : # Ctrl-N C64 TEXT MODE repurposed as refresh-page
self._redraw_screen()
# @TODO: also PgUp/Down, Home/End (Ctrl-A/Ctrl-E?)
# @TODO: how about insert/delete-char (which also may ins/del lines)? Or insert a blank line? We need chars for that as well.
# @TODO: clear-to-end-of-line/delete-char for a multi-terminal-line row may need to delete empty terminal lines
elif c in C64_COLORS.keys():
self._flush_dirty()
self._set_colors((C64_COLORS[c], self._current_colors[1]))
else: # regular printable character
self._write_printable(c, self._current_colors_or_reversed)
self._flush_dirty() # flush content to screen
self._lazy_scroll(self._term_loc[1])
# for now jam it straight to the window
self._blit_to_screen()
def _blit_to_screen(self):
if self._windowsurface is not self._term_surface:
self._windowsurface.blit(self._term_surface, (0,0))
pygame.display.update()
def _write_printable(self, c, colors):
if self._insert_printable: # this is set during editing, where newly typed chars should insert, not overwrite
self.write('\x94') # INS
if self._crsr_loc[0] == 0 and len(self._content) == self._crsr_loc[1]:
self._content.append([]) # create the new row
current_row = self._content[self._crsr_loc[1]]
content_tuple = (c, colors, True)
if self._crsr_loc[0] < len(current_row):
current_row[self._crsr_loc[0]] = content_tuple
else:
current_row.append(content_tuple)
# @TODO: if this extends an existing line beyond end of screen, we must insert a new line
self._advance_right()
self._add_to_dirty(1)
def _advance_right(self):
self._crsr_loc = (self._crsr_loc[0] + 1, self._crsr_loc[1])
self._term_loc = (self._term_loc[0] + 1, self._term_loc[1])
while self._term_loc[0] > self._term_size[0]:
self._term_loc = (self._term_loc[0] - self._term_size[0], self._term_loc[1] + 1)
def home(self):
self._flush_dirty()
self._crsr_loc = (0, 0)
self._term_loc = (0, 0)
if self._term_top_loc != (0, 0):
self._term_top_loc = (0, 0)
self._redraw_screen()
# clear screen and move to home position
def clr_scr(self):
self._show_cursor(False)
self._term_surface.fill(self._current_colors[1])
self._content = [[]] # list of list of tuple (char, (fg,bg), dirty)
self._dirty = 0 # nothing to flush anymore
self._term_top_loc = (0, 0) # prevent home() from clearing again
self.home()
def set_font(self, font):
if font is None:
self._font = pygame.font.Font(None, 18)
else:
self._font = font
cell_size = calcfontsize(self._font) # width and height of each cell in pixels
if self._half_height:
cell_size = (cell_size[0], cell_size[1] // 2)
if self._term_size is not None and self._cell_size != cell_size: # (it _term_size is None during __init__())
pass
# @TODO: rerender everything; maintain position, by keeping the top position in the top row
self._cell_size = cell_size
def set_term_size(self, size: Tuple[int, int]):
redraw = self._term_size is not None # and self._term_size != size
self._term_size = size
self._wndw_size = (size[0] * self._cell_size[0], size[1] * self._cell_size[1])
# set up the display and surfaces
full_screen = False
#if pygame.display.get_surface() is not None:
# flags = pygame.display.get_surface().get_flags()
if not full_screen:
self._windowsurface = pygame.display.set_mode(self._wndw_size, pygame.RESIZABLE)
self._managesdisplay = True
else:
#elif windowsurface == FULLSCREEN:
self._windowsurface = pygame.display.set_mode(self._wndw_size, pygame.FULLSCREEN)
self._managesdisplay = True
#else:
# self._windowsurface = windowsurface
# self._managesdisplay = False
self._autodisplayupdate = self._windowsurface is not None
self._autoblit = self._windowsurface is not None
self._term_surface = self._windowsurface
#self._term_surface = pygame.Surface(self._wndw_size)
#self._term_surface = self._term_surface.convert_alpha() # TODO - This is needed for erasing, but does this have a performance hit?
if redraw: # it is None during __init__
self._term_top_loc = (0, self._term_top_loc[1]) # get a clean starting point
self._redraw_screen()
#self._blit_to_screen()
def _set_wndw_size(self, size: Tuple[int, int]):
self.set_term_size((max(size[0] // self._cell_size[0], 1),
max(size[1] // self._cell_size[1], 1)))
# @TODO: reset the wind size to rounded values (unless full-screen)
def _show_cursor(self, show : bool = True):
if show == self._cursor_visible:
return
crsr_area = self._cursor_buffer.get_rect()
crsr_area.topleft = (self._term_loc[0] * self._cell_size[0], self._term_loc[1] * self._cell_size[1])
if show:
self._cursor_buffer.blit(self._term_surface, dest = (0,0), area = crsr_area) # save area under cursor
current_line = self._current_line()
current_cell = current_line[self._crsr_loc[0]] if self._crsr_loc[0] < len(current_line) else \
(' ', self._current_colors)
colors = current_cell[1]
ch = current_cell[0]
self._draw_text(self._term_loc, (colors[1], colors[0]), ch) # rerender char in reverse
else:
self._term_surface.blit(self._cursor_buffer, dest = crsr_area.topleft)
self._blit_to_screen() # for now
self._cursor_visible = show
# map Pygame key event to C64 PETSCII key (we only accept those)
# @TODO: extend to PgUp/PgDown, line-level Home/End (Ctrl-A, Ctrl-E?), maybe Ctrl-Left/Right?
# @BUGBUG: This totally fails with IMEs... No Chinese chars in Pygame (or is there?)
# @BUGBUG: This also requires self-interpretation of Shift. Dang.
def _map_key_event(self, event : pygame.event): # @TODO: add Pygame type
key = event.key
mod = event.mod
# Pygame special key
if key == pygame.K_LEFT:
return '\x9d'
elif key == pygame.K_RIGHT:
return '\x1d'
elif key == pygame.K_UP:
return '\x91'
elif key == pygame.K_DOWN:
return '\x11'
elif key == pygame.K_RETURN: # this is \r, not \n
return '\n'
elif key == K_HOME:
if (mod & pygame.KMOD_CTRL):
return '\x13'
else:
return '\x01' # (not C64) Ctrl-A
elif key == K_END:
if (mod & pygame.KMOD_CTRL):
return '\x84' # (not C64) jump to end of buffer
else:
return '\x04' # (not C64) misusing Ctrl-D
elif key == K_PAGEUP: # (not C64)
return '\x8f'
elif key == K_PAGEDOWN: # (not c64)
return '\x0f'
elif key == K_DELETE:
return '\x14'
elif key == K_INSERT:
return '\x94'
elif key >= 256:
return None
# normal keys
ch = self._key_to_char(key, mod) # create a normal char code
# some C64 special keys are Ctrl with regular-keys, e.g. Ctrl-1 or Ctrl-^
if (mod & pygame.KMOD_CTRL) and ch in C64_EXTRA_CONTROL_KEYS: # color keys
ch = C64_EXTRA_CONTROL_KEYS[ch]
return ch
# we only get lower-case keys, and must map Ctrl and Shift outselves... @TODO: find a module for that
def _key_to_char(self, key, mod):
# normal key or None
is_ctrl = (mod & pygame.KMOD_CTRL)
is_shift = (mod & pygame.KMOD_SHIFT)
if is_ctrl and key >= 0x61 and key <= 0x7f:
key -= 0x60
elif is_shift and chr(key) in US_ASCII_SHIFT_MAP:
return US_ASCII_SHIFT_MAP[chr(key)]
elif is_shift and key >= 0x60 and key <= 0x7f: # @BUGBUG: this is only for ASCII/US keyboard
key -= 0x20
return chr(key)
def _current_line(self):
return self._content[self._crsr_loc[1]]
def _handle_line_edit(self, key : str): # one character, actually
if key == '\n' or key == '\x8d':
res = ''.join(c[0] for c in self._current_line())
if self._input_mode >= 0:
res = res[self._input_mode:]
res = res.rstrip() # strip trailing spaces --@TODO: use a special end char to strip
self.write('\n')
return res + '\n'
elif key == '\x08': # DEL
self.write('\x08') # backspace
self.write('\x14') # C64 delete
elif key == ' ' and all(self._current_line()[i][0] >= '0' and self._current_line()[i][0] <= '9'
for i in range(self._crsr_loc[0])):
# insert spaces before the digits
num_len = self._crsr_loc[0]
insert_num = 6 - num_len
if insert_num <= 0:
insert_num = 1
self.write('\x01') # go to start of line
self.write('\x94' * insert_num) # push them over
self.write('\x1d' * (insert_num + num_len)) # cursor over to where we came from
self.write('\x94 ') # and now the space (insert in case there is more on the line)
# all other characters are handled by write() incl. C64 control chas such as color change, and ESC
elif key is not None:
self._insert_printable = True
self.write(key)
self._insert_printable = False
return None # unless it's Enter, return None
# read function
# returns:
# - entered line (incl.LF) in line mode (empty line == '\n')
# - single character in raw mode
# - '' in raw mode for wait == False if no char is available
# - None if window was closed
# mode:
# 0: raw-character mode
# -1: screen-editor
# 1: constrained one-line input right of current cursor position
# wait (raw mode only):
# True -> wait for key press
# False -> return '' immediately if no input available --@TODO: add a timeout
def read(self, mode : int = None, wait : bool = True):
# temp override of input mode
if mode is not None:
old_mode = self._input_mode
if mode > 0:
self._input_mode = self._crsr_loc[0]
elif mode < 0:
self._input_mode = -1
else:
self._input_mode = None # raw
res = self.read(mode = None, wait = wait)
self._input_mode = old_mode
return res
# input loop
while True:
self._show_cursor()
res = None
# @TODO: avoid spinning
# fetch new events if queue is empty
while not self._pending_events:
self._pending_events += pygame.event.get()
# process events
event = self._pending_events[0]
self._pending_events = self._pending_events[1:]
if event.type == pygame.QUIT:
self._show_cursor(False)
pygame.quit()
return None # end of file, so to speak
elif event.type == pygame.VIDEORESIZE:
new_wndw_size = (event.w, event.h)
if new_wndw_size != self._wndw_size:
self._set_wndw_size(new_wndw_size)
#elif event.type == MOVE: # ...handle windowing events here
# pass
elif event.type == pygame.KEYDOWN:
key = self._map_key_event(event) # (may return None for unrecognized keys)
if self._input_mode is None: # raw mode: that's it
res = key
else:
res = self._handle_line_edit(key) # returns either None or, upon Enter, the line
if res is not None:
break
elif not self._pending_events and self._input_mode is None and not wait:
res = '' # raw non-blocking mode/no new input
break
else:
continue # process more keys
self._show_cursor(False)
return res
def get(self, wait : bool = True):
# @TODO: these two functions are on the ourtside; control this via a ctrl sequence and/or ttymode
return self.read(mode = 0, wait = wait)
def input(self, prompt : str = ''): # set prompt = None for screen edit
if prompt is not None:
self.write(prompt)
res = self.read(mode = -1 if prompt is None else 1)
if res is not None:
res = res[:-1] # strip '\n'
return res
def __init__(self, width=80, height=25, font=None, fgcolor=DEFAULTFGCOLOR, bgcolor=DEFAULTBGCOLOR,
half_height = False, windowsurface=None):
"""
Creates a new PygcurseTerminalSurface object.
- width and height are the number of characters the the object can display.
- font is a pygame.Font object used to display the characters. PygcurseSurface can only display one font of one size at a time. The size of the underlying pygame.Surface object is calculated from the font size and width/height accordingly. If None, then a default generic font is used.
- fgcolor is the foreground color (ie the color of the text). It is set to either a pygame.Color object, an RGB tuple, an RGBA tuple, or a string that is a key in the colornames dict.
- bgcolor is the background color of the text.
- windowSurface is optional. If None, than the user is responsible for calling the update() method on this object and blitting it's surface to the screen, and calling pygame.display.update(). If a pygame.Surface object is specified, then PygcurseSurface object handles updating automatically (unless disabled). (See the update() method for more details.)
"""
self._members()
self._half_height = half_height
#import ctypes
#ctypes.windll.user32.SetProcessDPIAware() # operate at native resolution
# ^^ makes no difference it seems
pygame.init()
pygame.key.set_repeat(500, 50)
#NATIVE = pygame.display.Info().current_w,pygame.display.Info().current_h
#NATIVE = (ctypes.windll.user32.GetSystemMetrics(0),ctypes.windll.user32.GetSystemMetrics(1))
self._term_size = None # tells set_font() and set_term_size() to not attempt to redraw
self.set_font(font)
self.set_term_size((width, height))
self._cursor_buffer = pygame.Surface(self._cell_size)
self._set_colors((fgcolor, bgcolor))
self._term_top_loc = (0, 0) # prevent home() from attempting to redraw
self._content = None # tells clear() to invalidate the whole screen
self.clr_scr()
def input1(self, prompt='', maxlength=None, fgcolor=None, bgcolor=None, promptfgcolor=None, promptbgcolor=None, whitelistchars=None, blacklistchars=None, callbackfn=None, fps=None):
"""
A pygcurse version of the input() and raw_input() functions. When called, it displays a cursor on the screen and lets the user type in a string. This function blocks until the user presses Enter, and it returns the string the user typed in.
This implements the screen editor:
- two modes:
- no prompt: user may move around freely (like CBM screen editor), and
when user hits Enter, line under cursor is returned (and cursor positioned on next line).
To e.g. pre-populate a line number, just print it first without newline.
- non-None prompt: user is restricted to current line, and cannot move into the prompt; up/down cycles through prompt history
- callback allows e.g. Python auto-indent or auto-number
This is implemented via a state class, which can also be called under user control.
In fact, this function can be used as a drop-in replacement of Python's input() to convert a stdio text-based Python program to a graphical Pygcurse program. See the PygTerm64 class for details.
- prompt is a string that is displayed at the beginning of the input area
- maxlength is the maximum number of characters that the user can enter. By default it is 4094 characters if the keyboard input can span multiple lines, or to the end of the current row if the x value is specified.
- fgcolor and bgcolor are the foreground and background colors of the text typed by the user.
- promptfgcolor and promptbgcolor are the foreground and background colors of the prompt.
- whitelistchars is a string of the characters that are allowed to be entered from the keyboard. If None, then all characters (except those in the blacklist, if one is specified) are allowed.
- blacklistchars is a string of the characters that are prohibited to be entered from the keyboard. If None, then all characters (if they are in the whitelist, if one is specified) are allowed.
- callbackfn is a function that is called during the input() method's loop. This can be used for any additional code that needs to be run while waiting for the user to enter text.
- fps specifies how many times per second this function should update the screen (ie, frames per second). If left at None, then input() will simply try to update as fast as possible.
"""
if fps is not None:
clock = pygame.time.Clock()
inputObj = PygcurseInput(self, prompt, x, y, maxlength, fgcolor, bgcolor, promptfgcolor, promptbgcolor, whitelistchars, blacklistchars)
self.inputcursor = inputObj.startx, inputObj.starty
while True: # the event loop
self._inputcursormode = inputObj.insertMode and 'insert' or 'underline'
for event in pygame.event.get((KEYDOWN, KEYUP, QUIT)): # TODO - handle holding down the keys
if event.type == QUIT:
pygame.quit()
sys.exit()
elif event.type in (KEYDOWN, KEYUP):
inputObj.sendkeyevent(event)
if inputObj.done:
return ''.join(inputObj.buffer)
if callbackfn is not None:
callbackfn()
inputObj.update()
self.update()
if fps is not None:
clock.tick(fps)
raw_input = input
# This code makes my eyes (and IDEs) bleed (and maintenance a nightmare), but it's the only way to have syntactically correct code that is compatible with both Python 2 and Python 3:
if sys.version.startswith('2.'): # for Python 2 version
exec(r'''
def pygprint(self, *objs): # PY2
"""
Displays text to the PygcurseSurface. The parameters work exactly the same as Python's textual print() function. It can take several arguments to display, each separated by the string in the sep parameter. The end parameter string is automatically added to the end of the displayed output.
- fgcolor, bgcolor are colors for the text displayed by this call to print(). If None, then the PygcurseSurface object's fg and bg colors are used. These parameters only apply to the text printed by this function call, they do not change the PygcurseSurface's fg and bg color settings.
This function can be used as a drop-in replacement of Python's print() to convert a stdio text-based Python program to a graphical Pygcurse program. See the PygTerm64 class for details.
"""
self.write(' '.join([str(x) for x in objs]) + '\n')
''')
else: # for Python 3 version
exec(r'''
def pygprint(self, obj='', *objs, sep=' ', end='\n', fgcolor=None, bgcolor=None, x=None, y=None):
"""
Displays text to the PygcurseSurface. The parameters work exactly the same as Python's textual print() function. It can take several arguments to display, each separated by the string in the sep parameter. The end parameter string is automatically added to the end of the displayed output.
- fgcolor, bgcolor are colors for the text displayed by this call to print(). If None, then the PygcurseSurface object's fg and bg colors are used. These parameters only apply to the text printed by this function call, they do not change the PygcurseSurface's fg and bg color settings.
This function can be used as a drop-in replacement of Python's print() to convert a stdio text-based Python program to a graphical Pygcurse program. See the PygTerm64 class for details.
"""
writefgcolor = (fgcolor is not None) and getpygamecolor(fgcolor) or self._fgcolor
writebgcolor = (bgcolor is not None) and getpygamecolor(bgcolor) or self._bgcolor
if x is not None:
self.cursorx = x
if y is not None:
self.cursory = y
text = [str(obj)]