'OHRRPGCE - Game/Custom general menu code '(C) Copyright 1997-2023 James Paige, Ralph Versteegen, and the OHRRPGCE Developers 'Dual licensed under the GNU GPL v2+ and MIT Licenses. Read LICENSE.txt for terms and disclaimer of liability. #include "config.bi" #include "udts.bi" #include "const.bi" #include "scrconst.bi" #include "allmodex.bi" #include "common.bi" #include "menus.bi" #include "loading.bi" #IFDEF IS_GAME #include "scriptcommands.bi" #ENDIF 'Local functions DECLARE SUB LoadMenuItems(menu_set as MenuSet, dat as MenuDef, byval record as integer) DECLARE SUB LoadMenuItem(byval f as integer, items() as MenuDefItem ptr, byval record as integer) DECLARE SUB SaveMenuItems(menu_set as MenuSet, dat as MenuDef, byval record as integer) DECLARE SUB SaveMenuItem(byval f as integer, mi as MenuDefItem, byval record as integer, byval menunum as integer, byval itemnum as integer) 'TypeTables DEFINE_VECTOR_OF_CLASS(BasicMenuItem, BasicMenuItem) DEFINE_VECTOR_OF_CLASS(SimpleMenuItem, SimpleMenuItem) DEFINE_VECTOR_OF_CLASS(MenuDefItem, MenuDefItem) ' If > 0, enable mouse controls of menus even if running a game that has disabled that. ' This can be incremented while we are inside a debug/engine menu (to allow nesting). DIM force_use_mouse as integer = 0 '========================================================================================== ' Generic MenuState Stuff '========================================================================================== FUNCTION MenuState.empty() as bool RETURN last < first END FUNCTION 'Whether there is a selected item FUNCTION MenuState.pt_valid() as bool RETURN pt >= first AND pt <= last END FUNCTION '(Re-)initialise menu state, preserving .pt if valid '.pt is moved to a selectable menu item. SUB init_menu_state (byref state as MenuState, menu() as SimpleMenuItem, menuopts as MenuOptions = MenuOptions()) WITH state DIM position_was_known as bool = .position_known 'Position not known before it's actually drawn, fill with dummy data for now calc_menu_rect state, menuopts, .rect.xy .position_known = position_was_known .first = LBOUND(menu) .last = UBOUND(menu) IF .size <= 0 THEN .size = 20 'This is silly, which is why .autosize is (almost?) always used IF .empty() THEN .pt = .first - 1 ELSE .pt = bound(.pt, .first, .last) '.first <= .last IF menu(.pt).unselectable THEN .pt = .first - 1 'nothing selectable FOR i as integer = 0 TO UBOUND(menu) IF menu(i).unselectable = NO THEN .pt = i: EXIT FOR NEXT END IF 'Menus with unselectable items have lookahead, which these +1,-1 'attempt to simulate. Not perfect, but prevents some flickering '(TODO: modify correct_menu_state and all usemenu overloads to do this too) IF .pt <> -1 THEN .top = bound(.top, .pt - .size + 1, .pt - 1) END IF IF .position_known = NO ORELSE in_bound(.hover, .first, .last) = NO THEN .hover = .first - 1 END IF .top = bound(.top, 0, large(.last - .size, 0)) END WITH END SUB '(Re-)initialise menu state, preserving .pt if valid '.pt is moved to a selectable menu item. ' 'menu may in fact be a vector of any type inheriting from BasicMenuItem. 'menu's typetable tells the size in bytes of each menu item SUB init_menu_state (byref state as MenuState, byval menu as BasicMenuItem vector, menuopts as MenuOptions = MenuOptions()) WITH state DIM position_was_known as bool = .position_known 'Position not known before it's actually drawn, fill with dummy data for now calc_menu_rect state, menuopts, .rect.xy .position_known = position_was_known .first = 0 .last = v_len(menu) - 1 IF .size <= 0 THEN .size = 20 IF .position_known = NO ORELSE in_bound(.hover, .first, .last) = NO THEN .hover = .first - 1 END IF IF v_len(menu) = 0 THEN .pt = -1 ELSE .pt = bound(.pt, .first, .last) '.first <= .last IF v_at(menu, .pt)->unselectable THEN .pt = -1 'explicitly -1 when nothing selectable FOR i as integer = 0 TO v_len(menu) - 1 IF v_at(menu, i)->unselectable = NO THEN .pt = i: EXIT FOR NEXT END IF 'Menus with unselectable items have lookahead, which these +1,-1 'attempt to simulate. Not perfect, but prevents some flickering '(TODO: modify correct_menu_state and all usemenu overloads to do this too) IF .pt <> -1 THEN .top = bound(.top, .pt - .size + 1, .pt - 1) END IF .top = bound(.top, 0, large(.last - .size, 0)) END WITH END SUB SUB calc_menustate_size (byref state as MenuState) 'For .autosized menus (standardmenu, not MenuDef) only! 'Run this once per frame for a menu that should fill the whole screen vertically 'Does not work unless .spacing is set correctly (set on call to standardmenu) 'TODO: should pass the page to be drawn to to this sub. WITH state IF .spacing = 0 THEN ' This error is currently impossible IF .position_known = YES THEN debugc errBug, "calc_menustate_size: .spacing didn't get set" EXIT SUB END IF 'By default, leave padding of 4 pixels at the top and 2 at the bottom IF .autosize_ignore_pixels = 0 AND .autosize_ignore_lines = 0 THEN .autosize_ignore_pixels = 6 DIM ignorepix as integer = .autosize_ignore_pixels + .autosize_ignore_lines * large(.spacing, 10) .size = (vpages(dpage)->h - ignorepix) \ .spacing - 1 END WITH END SUB 'Simple... and yet, more options than a regular menu item 'Can also insert instead of appending... bad name SUB append_simplemenu_item (byref menu as SimpleMenuItem vector, caption as zstring ptr, byval unselectable as bool = NO, byval col as integer = 0, byval dat as integer = 0, byval where as integer = -1) IF where = -1 THEN v_expand menu, 1 where = v_len(menu) - 1 END IF WITH menu[where] .text = *caption .col = col .bgcol = 0 .unselectable = unselectable .disabled = NO .dat = dat END WITH END SUB ' Calculate the on-screen position and size for a menu item in a non-MenuDef menu. ' (See also menudef_item_rect) FUNCTION standardmenu_item_rect(state as MenuState, menutext as string, index as integer) as RectType DIM itempos as XYPair = state.rect.xy itempos.y += state.spacing * (index - state.top) RETURN XY_WH(itempos, textsize(menutext)) END FUNCTION FUNCTION standardmenu_item_rect(menu as BasicMenuItem vector, state as MenuState) as RectType RETURN standardmenu_item_rect(state, v_at(menu, state.pt)->text, state.pt) END FUNCTION 'Is pos on menutext, which is the 'index'th menu item in the menu with given state? FUNCTION menutext_hit_tester(menutext as string, state as MenuState, index as integer, pos as XYPair) as bool 'IF NOT state.position_known THEN RETURN NO 'Unnecessary? RETURN rect_collide_point(standardmenu_item_rect(state, menutext, index), pos) END FUNCTION 'A function usable as MenuState.hit_test if using standardmenu with a string array. 'Set state.hit_test_data = @menu(0) FUNCTION stringmenu_hit_tester(state as MenuState, index as integer, pos as XYPair) as bool IF state.hit_test_data = NULL THEN RETURN NO DIM byref menutext as string = cptr(string ptr, state.hit_test_data)[index] RETURN menutext_hit_tester(menutext, state, index, pos) END FUNCTION FUNCTION find_menu_item_at_point (state as MenuState, pos as XYPair) as integer 'If the on-screen position overlaps a MenuState, return the index of the menu item it is touching. 'Return a value < state.first if it's not on any menu item. WITH state IF .position_known THEN DIM mpt as integer = rect_collide_point_vertical_chunk(.rect, pos, .spacing) IF mpt > -1 THEN mpt += .top ' Need to check against size, as there might be a few pixels inside .rect after ' the last visible menu item, where the next item isn't drawn IF mpt >= .first ANDALSO mpt <= .last ANDALSO mpt <= .top + .size THEN ' Optionally do a fine-grained test that it is horizontally on the menu item IF state.hit_test = NULL ORELSE state.hit_test(state, mpt, pos) THEN RETURN mpt 'Success END IF END IF END IF END IF RETURN .first - 1 'Mouse is not over a menu item! END WITH END FUNCTION ' Updates state.hover, and returns YES if the mouse is over the menu. ' (You only need this if not calling usemenu or scrollmenu) FUNCTION mouse_update_hover (state as MenuState) as bool DIM use_mouse as bool = YES #IFDEF IS_GAME use_mouse = get_gen_bool("/mouse/mouse_menus") OR (force_use_mouse > 0) #ENDIF IF use_mouse ANDALSO readmouse.active THEN state.hover = find_menu_item_at_point(state, readmouse.pos) RETURN state.hover >= state.first ELSE state.hover = state.first - 1 RETURN NO END IF END FUNCTION ' Updates state.pt for clicks/drags. This should be called only after mouse_update_hover has ' updated state.hover and returned YES, if state.hover is a selectable item. SUB mouse_update_selection (state as MenuState) DIM buttons as integer = (readmouse.buttons OR readmouse.release) IF state.select_by_mouse_release THEN buttons = readmouse.release IF (buttons AND mouseLeft) ANDALSO readmouse.drag_dist < 10 THEN 'Don't change selection while dragging, because dragging might be used 'for scrolling (if mouse_drag_menu called) state.pt = state.hover ELSEIF (buttons AND mouseRight) ANDALSO readmouse.drag_dist < 10 THEN 'Right button only selects if this is NOT a drag state.pt = state.hover ELSEIF state.drag_selecting AND buttons THEN 'Popup menus do allow drag to select (and drag to confirm) state.pt = state.hover END IF END SUB 'Handle just mouse controls, not keyboard. Doesn't support selectable() 'or .autosize! SUB usemenu_mouse_only(state as MenuState) IF mouse_update_hover(state) THEN 'ANDALSO selectable(.hover) THEN mouse_update_selection(state) END IF mouse_scroll_menu state END SUB ' This does a subset of what usemenu does, call this after modifying .pt, .last, .first or .size ' if not immediately calling usemenu. SUB correct_menu_state (state as MenuState) WITH state IF .empty() THEN .pt = .first - 1 ELSE .pt = bound(.pt, .first, .last) END IF END WITH correct_menu_state_top state mouse_update_hover state END SUB ' Just fixup state.top SUB correct_menu_state_top (state as MenuState) WITH state ' If the bottom of the menu is above the bottom of the screen, scroll up .top = large(small(.top, .last - .size), .first) ' Selected item must be visible (unless the menu is empty) IF .pt_valid() THEN .top = bound(.top, .pt - .size, .pt) END WITH END SUB ' Adjust .top and .pt according to mouse wheel input. SUB mouse_scroll_menu(byref state as MenuState) WITH state DIM lasttop as integer = large(.first, .last - .size) IF .hover >= .first THEN 'Mouse over the menu IF (readmouse().buttons AND mouseRight) = 0 THEN 'While right button down, scroll wheel affects intgrabber .top = bound(.top + (4 * readmouse().wheel_delta) \ 120, .first, lasttop) ' Make sure .pt is visible .pt = bound(.pt, .top, .top + .size) END IF END IF END WITH END SUB ' By default, menus do not support scrolling by dragging the menu unless this is called. SUB mouse_drag_menu(byref state as MenuState, byval button as MouseButton=mouseLeft, byval threshold as integer=10, byval magnify as double=1.0) WITH state IF .spacing = 0 THEN EXIT SUB END IF IF (readmouse.dragging AND button) = 0 THEN 'No drag is happening .drag_start_top = .first - 1 END IF DIM lasttop as integer = large(.first, .last - .size) IF .hover >= .first THEN 'Mouse over the menu IF (readmouse.dragging AND button) THEN IF .drag_start_top < .first THEN .drag_start_top = state.top END IF IF readmouse.drag_dist > threshold THEN DIM dist as integer = INT((readmouse.clickstart.y - readmouse.pos.y) / .spacing * magnify) .top = bound(.drag_start_top + dist, .first, lasttop) ' Make sure .pt is visible .pt = bound(.pt, .top, .top + .size) END IF END IF END IF END WITH END SUB FUNCTION usemenu (byref state as MenuState, byval deckey as KBScancode = ccUp, byval inckey as KBScancode = ccDown) as bool WITH state IF .autosize THEN calc_menustate_size state END IF DIM oldptr as integer = .pt DIM oldtop as integer = .top IF .first < .last THEN IF keyval(deckey) > 1 THEN loopvar .pt, .first, .last, -1 IF keyval(inckey) > 1 THEN loopvar .pt, .first, .last, 1 IF keyval(scPageup) > 1 THEN .pt -= .size IF keyval(scPagedown) > 1 THEN .pt += .size IF keyval(scHome) > 1 THEN .pt = .first IF keyval(scEnd) > 1 THEN .pt = .last END IF correct_menu_state state 'Update .top and .pt usemenu_mouse_only state 'IF mouse_update_hover(state) THEN mouse_update_selection(state) 'mouse_scroll_menu state IF oldptr = .pt AND oldtop = .top THEN RETURN NO ELSE reset_menu_edit_state RETURN YES END IF END WITH END FUNCTION FUNCTION usemenu (byref pt as integer, byref top as integer, byval first as integer, byval last as integer, byval size as integer, byval deckey as KBScancode = ccUp, byval inckey as KBScancode = ccDown) as bool 'FIXME: eventually phase this out by making sure that all callers to usemenu have their own MenuState DIM state as MenuState WITH state .pt = pt .top = top .first = first .last = last .size = size END WITH DIM result as bool = usemenu(state, deckey, inckey) WITH state pt = .pt top = .top first = .first last = .last size = .size END WITH RETURN result END FUNCTION 'a version for menus with unselectable items, skip items for which menudata[i].unselectable = YES 'menu may in fact be a vector of any type inheriting from BasicMenuItem. 'menu's typetable tells the size in bytes of each menu item FUNCTION usemenu (state as MenuState, byval menudata as BasicMenuItem vector, byval deckey as KBScancode = ccUp, byval inckey as KBScancode = ccDown) as bool IF state.empty() OR v_len(menudata) = 0 THEN correct_menu_state state RETURN NO END IF DIM selectable(v_len(menudata) - 1) as bool FOR idx as integer = 0 TO v_len(menudata) - 1 selectable(idx) = NOT v_at(menudata, idx)->unselectable NEXT RETURN usemenu(state, selectable(), deckey, inckey) END FUNCTION 'A version for MenuDef. FUNCTION usemenu (state as MenuState, menu as MenuDef, byval deckey as KBScancode = ccUp, byval inckey as KBScancode = ccDown) as bool IF state.empty() THEN correct_menu_state state RETURN NO END IF DIM selectable(state.last) as bool FOR idx as KBScancode = 0 TO state.last selectable(idx) = NOT menu.items[idx]->unselectable NEXT RETURN usemenu(state, selectable(), deckey, inckey) END FUNCTION 'a version for menus with unselectable items, skip items for which selectable(i) = NO FUNCTION usemenu (state as MenuState, selectable() as bool, byval deckey as KBScancode = ccUp, byval inckey as KBScancode = ccDown) as bool IF state.empty() THEN correct_menu_state state RETURN NO END IF WITH state IF .autosize THEN calc_menustate_size state END IF 'TODO: .size for MenuDef menus is usually correct, since it's set by init_menu_state and draw_menu, but it's 'possible that the menu data has just been changed 'Check there are selectable items DIM has_selectable as bool = NO FOR i as integer = .first TO .last IF selectable(i) THEN has_selectable = YES : EXIT FOR NEXT IF has_selectable = NO THEN .pt = .first - 1 RETURN scrollmenu(state, deckey, inckey) END IF DIM as integer oldptr, oldtop, d, moved_d oldptr = .pt oldtop = .top d = 0 moved_d = 0 IF keyval(deckey) > 1 THEN d = -1 IF keyval(inckey) > 1 THEN d = 1 IF keyval(scPageup) > 1 THEN .pt = large(.pt - .size, .first) WHILE selectable(.pt) = 0 AND .pt > .first : loopvar(.pt, .first, .last, -1) : WEND IF selectable(.pt) = 0 THEN d = 1 moved_d = -1 END IF IF keyval(scPagedown) > 1 THEN .pt = small(.pt + .size, .last) WHILE selectable(.pt) = 0 AND .pt < .last : loopvar(.pt, .first, .last, 1) : WEND IF selectable(.pt) = 0 THEN d = -1 moved_d = 1 END IF IF keyval(scHome) > 1 THEN .pt = .last : d = 1 IF keyval(scEnd) > 1 THEN .pt = .first : d = -1 IF d THEN moved_d = d DO .top = bound(.top, .pt - .size, .pt) loopvar .pt, .first, .last, d LOOP WHILE selectable(.pt) = 0 END IF IF moved_d THEN 'we look ahead of the actual cursor, to bring unselectable items at the ends of the menu into view DIM lookahead as integer = .pt DO lookahead += moved_d LOOP WHILE bound(lookahead, .first, .last) = lookahead ANDALSO selectable(lookahead) = 0 lookahead = bound(lookahead, .first, .last) .top = bound(.top, lookahead - .size, lookahead) END IF correct_menu_state state 'Update .top IF mouse_update_hover(state) ANDALSO selectable(.hover) THEN mouse_update_selection(state) END IF mouse_scroll_menu state IF oldptr = .pt AND oldtop = .top THEN RETURN NO ELSE reset_menu_edit_state RETURN YES END IF END WITH END FUNCTION 'Standard controls for using and exiting a menu. 'Sets state.active = NO and returns either state.pt or escval to exit the menu FUNCTION default_menu_controls(state as MenuState, escval as integer = -1) as integer IF keyval(ccCancel) > 1 THEN state.active = NO RETURN escval END IF IF readmouse.release AND (mouseLeft OR mouseRight) THEN IF NOT rect_collide_point(state.rect, readmouse.pos) THEN state.active = NO RETURN escval END IF END IF IF enter_space_click(state) THEN state.active = NO RETURN state.pt END IF usemenu state END FUNCTION 'scrollmenu is like usemenu for menus where no menu item is selected: 'you just want to scroll a menu up and down (modifies .top; .pt is ignored). 'Returns true when view changed. FUNCTION scrollmenu (state as MenuState, byval deckey as KBScancode = ccUp, byval inckey as KBScancode = ccDown) as bool WITH state IF .autosize THEN calc_menustate_size state END IF DIM oldtop as integer = .top DIM lasttop as integer = large(.first, .last - .size) IF keyval(deckey) > 1 THEN loopvar .top, .first, lasttop, -1 IF keyval(inckey) > 1 THEN loopvar .top, .first, lasttop, 1 IF keyval(scPageup) > 1 THEN .top = large(.first, .top - .size) IF keyval(scPagedown) > 1 THEN .top = small(lasttop, .top + .size) IF keyval(scHome) > 1 THEN .top = .first IF keyval(scEnd) > 1 THEN .top = lasttop mouse_update_hover(state) mouse_scroll_menu(state) RETURN (.top <> oldtop) END WITH END FUNCTION 'shaded: both disabled and unselectable SUB standard_to_basic_menu (menu() as string, byref state as MenuState, byref basicmenu as BasicMenuItem vector, byval shaded as bool ptr = NULL) v_new basicmenu, state.last - state.first + 1 FOR i as integer = 0 TO state.last - state.first WITH basicmenu[i] .text = menu(state.first + i) .col = 0 'Default, usually uilook(uiMenuItem) IF shaded THEN .disabled = shaded[state.first + i] .unselectable = .disabled END IF END WITH NEXT END SUB ' Overload for standardmenu. Not to be confused with calc_menustate_size, which only sets MenuState.size. ' Initialises/updates size and position data in MenuState, including .rect, and .size ' if .autosize is true. ' Used if you want to calculate the size of the menu before the first call to standardmenu, ' but unless you're using menuopts.calc_size, calling init_menu_state is sufficient. ' (Note: 'menu' is optional, needed only to calculate width.) ' Sets .position_known=YES to indicate that these members have been initialised, ' even if not called from standardmenu. SUB calc_menu_rect(state as MenuState, menuopts as MenuOptions, pos as RelPosXY, page as integer = -1, menu as BasicMenuItem vector = NULL) IF page = -1 THEN page = vpage WITH state .spacing = 9 + menuopts.itemspacing ' TODO: calc_menu_rect used to call calc_menustate_size unconditionally. So still ' need to call it if .size is 0. But would be cleaner to just default .autosize to YES IF .autosize OR (.size = 0) THEN ' Calculate .size. ' usemenu also calls calc_menustate_size, but usemenu might not be called if the ' menu is inactive (more than one on-screen), and on the first tick with ' .autosize=YES before .spacing is set the correct .size won't be known either. ' (In that case, you should call init_menu_state, passing menuopts if used.) calc_menustate_size state END IF ' Width DIM wide as integer = menuopts.wide IF menuopts.calc_size ANDALSO menu THEN ' Widen the menu according to widest menu item ' (Note this is done unconditionally for MenuDef menus) FOR i as integer = 0 TO small(v_len(menu) - 1, .last) wide = large(wide, textwidth(v_at(menu, i)->text)) NEXT ' Don't count a fullscreen scrollbar as being part of the menu. Probably doesn't matter. IF NOT menuopts.no_scrollbar AND NOT menuopts.fullscreen_scrollbar THEN wide += 6 END IF .rect.wide = small(vpages(page)->w, wide) ' Height DIM num_menu_items as integer IF menuopts.calc_size THEN num_menu_items = small(.size, .last - .first) + 1 ELSE num_menu_items = .size + 1 'Might be larger than actual number of items END IF .rect.high = small(vpages(page)->h, num_menu_items * .spacing) ' If autosized, maybe the menu rect, and therefore the scrollbar, should reach ' to the bottom of the screen regardless of autosize_ignore_lines/pixels? We ' use .fullscreen_scrollbar in several places because of that. 'IF .autosize THEN .rect.high = vpages(page)->h ' Now position the menu, and clamp to screen size .rect.xy = relative_pos(pos, vpages(page)->size, .rect.wh) .position_known = YES .rect.wide = small(.rect.wide, vpages(page)->w - .rect.x) .rect.high = small(.rect.high, vpages(page)->h - .rect.y) END WITH END SUB SUB standardmenu (menu() as string, byref state as MenuState, x as RelPos = pMenuX, y as RelPos = pMenuY, page as integer, menuopts as MenuOptions) DIM basicmenu as BasicMenuItem vector standard_to_basic_menu menu(), state, basicmenu 'Shift menu items so that state.first = 0 DIM first as integer = state.first state.top -= first state.pt -= first state.hover -= first state.last -= first state.first = 0 standardmenu basicmenu, state, x, y, page, menuopts state.top += first state.pt += first state.hover += first state.last += first state.first = first v_free basicmenu END SUB 'Version which allows items to be "shaded" (they are both disabled/greyed out and 'unselectable (not affected by mouse hover)... which is often not what you want) SUB standardmenu (menu() as string, byref state as MenuState, shaded() as bool, x as RelPos = pMenuX, y as RelPos = pMenuY, page as integer, menuopts as MenuOptions) BUG_IF(LBOUND(shaded) > LBOUND(menu) ORELSE UBOUND(shaded) < UBOUND(menu), "shaded() too small") DIM basicmenu as BasicMenuItem vector standard_to_basic_menu menu(), state, basicmenu, @shaded(0) 'Shift menu items so that state.first = 0 DIM first as integer = state.first state.top -= first state.pt -= first state.hover -= first state.last -= first state.first = 0 standardmenu basicmenu, state, x, y, page, menuopts state.top += first state.pt += first state.hover += first state.last += first state.first = first v_free basicmenu END SUB 'Draws an undecorated menu (just lines of coloured text, and optionally a scrollbar), as used throughout Custom. '(The in-game user-customisable menus are drawn with draw_menu.) 'menu may in fact be a vector of any type inheriting from BasicMenuItem: ' standardmenu cast(BasicMenuItem vector, menu), ... 'The vector's internal typetable tells the size in bytes of each menu item SUB standardmenu (byval menu as BasicMenuItem vector, state as MenuState, x as RelPos = pMenuX, y as RelPos = pMenuY, page as integer, menuopts as MenuOptions) 'The following doesn't affect simple string array menus which are converted to BasicMenuItem menus BUG_IF(state.first <> 0, "state.first <> 0 not supported for BasicMenuItem menus!") calc_menu_rect state, menuopts, XY(x, y), page, menu DIM wide as integer = state.rect.wide 'calc_menu_rect solved from RelPos to screen positions x = state.rect.x y = state.rect.y IF menuopts.edged THEN 'Edged font is drawn at +1,+1 IF x > 0 THEN x -= 1 IF y > 0 THEN y -= 1 END IF IF state.active THEN state.tog XOR= 1 END IF IF NOT menuopts.no_scrollbar THEN IF menuopts.fullscreen_scrollbar THEN draw_fullscreen_scrollbar state, 0, page ELSE draw_scrollbar state, state.rect, 0, page END IF END IF DIM rememclip as ClipState = get_cliprect() shrinkclip x, , x + wide - 1, , vpages(page) FOR i as integer = state.top TO state.top + state.size IF i < v_len(menu) THEN 'NOTE: we use v_at() instead of menu[i] to support derived types of BasicMenuItem! WITH *v_at(menu, i) DIM linewidth as integer = textwidth(.text, IIF(menuopts.edged, fontEdged, fontPlain)) IF .bgcol THEN 'Note that BasicMenuItem.bgcol draws a rectangle across the width of the menu, while 'menuopts.highlight/.drawbg or a text bg color is across the width of the text rectangle x + 0, y, wide, state.spacing, .bgcol, page END IF IF state.pt = i AND state.active AND menuopts.highlight_selection THEN rectangle x + 0, y, IIF(linewidth, linewidth, wide), state.spacing, uilook(uiHighlight), page ELSEIF menuopts.drawbg THEN trans_rectangle vpages(page), XYWH(x, y, linewidth, state.spacing), curmasterpal(uilook(uiBackground)), 0.55 END IF DIM col as integer col = menu_item_color(state, i, .disabled, .unselectable, .col, .disabled_col, menuopts.normal_col, menuopts.disabled_col) DIM drawx as integer = x IF linewidth > wide AND state.active THEN IF menuopts.nevershowright = NO AND (state.pt = i OR menuopts.showright) THEN drawx = x + wide - linewidth END IF END IF IF menuopts.edged THEN edgeprint .text, drawx, y, col, page, YES ELSE textcolor col, 0 printstr .text, drawx, y, page, YES END IF y += state.spacing END WITH END IF NEXT i get_cliprect() = rememclip END SUB 'Determine the color to draw menu item index 'itemno' in menu 'state'. 'c_normal and c_disabled: ' The normal and disabled colors for the menu item. ' They can be 0, indicating to use the def_normal and def_disabled ' defaults instead, or < 0 to use a UI color (decoded with ColorIndex). ' This is a double-fallback, useful in some weird existing code. 'disabled: whether this item is disabled 'unselectable: whether this item is unselectable FUNCTION menu_item_color(state as MenuState, itemno as integer, disabled as bool = NO, unselectable as bool = NO, c_normal as integer = 0, c_disabled as integer = 0, def_normal as integer = -uiMenuItem-1, def_disabled as integer = -uiDisabledItem-1) as integer DIM col as integer IF disabled THEN IF state.pt = itemno AND state.active THEN col = uilook(uiSelectedDisabled + state.tog) ELSE IF def_disabled = 0 THEN def_disabled = -uiDisabledItem - 1 col = IIF(c_disabled, c_disabled, def_disabled) END IF ELSE IF state.pt = itemno AND state.active THEN col = uilook(uiSelectedItem + state.tog) ELSE IF def_normal = 0 THEN def_normal = -uiMenuItem - 1 col = IIF(c_normal, c_normal, def_normal) END IF END IF col = ColorIndex(col) IF state.hover = itemno AND state.pt <> itemno AND state.active AND unselectable = NO THEN col = mouse_hover_tinted_color(col) END IF RETURN col END FUNCTION '========================================================================================== ' Selection by typing '========================================================================================== CONST SELECT_TYPE_TIMEOUT as double = 0.7 'If the user typed something, returns YES and set select_query to a search string, to be used 'with e.g. INSTR or find_on_word_boundary_excluding, or select_on_word_boundary[_excluding] 'which handles all the details. 'Special care required on menus using strgrabber, or even intgrabber. 'Generally you should pass accept_numbers=NO on menus using intgrabber. 'Example usage: ' setkeys YES ' ' IF select_by_typing(selectst) THEN ' select_on_word_boundary menu(), selectst, state ' END IF ' ' highlight_menu_typing_selection menu(), menu_display(), selectst, state ' standardmenu menu_display(), state, , , dpage ' FUNCTION select_by_typing(selectst as SelectTypeState, byval allow_numbers as bool = YES) as bool WITH selectst IF TIMER - .last_input_time > SELECT_TYPE_TIMEOUT THEN select_clear selectst END IF DIM intext as string = getinputtext IF LEN(intext) = 0 THEN RETURN NO 'Only test first character for simplicity IF allow_numbers = NO ANDALSO (isdigit(intext[0]) ORELSE intext[0] = ASC("-")) THEN RETURN NO .last_input_time = TIMER .buffer += LCASE(intext) 'If user has just typed the same character repeatedly, only search by that, otherwise by whole input '...unless the user is entering a number IF isdigit(.buffer[0]) THEN .query = .buffer ELSE .query = LEFT(.buffer, 1) FOR i as integer = 0 TO LEN(.buffer) - 1 IF .buffer[i] <> .query[0] THEN .query = .buffer EXIT FOR END IF NEXT END IF RETURN YES END WITH END FUNCTION 'Manually set select_by_typing state to inactive SUB select_clear(selectst as SelectTypeState) selectst.buffer = "" selectst.query = "" selectst.highlight_at = 1 END SUB 'Copies contents of menu() to menu_display(), with text markup added to highlight 'text matching selectst.query. 'The calling menu should set selectst.query_at to the offset in menu(state.pt) which matches 'the query, or 0 if no match, after doing text matching after calling select_by_typing. 'Can also set query_at to -1 for an invisible match (no highlighting) '(The select_on_word_boundary SUB handles this.) ' 'Idea: could highlight all matches rather than just one on the current line SUB highlight_menu_typing_selection(menu() as string, menu_display() as string, selectst as SelectTypeState, state as MenuState) 'If the user moves the cursor normally, reset '(Yes, this is a rather regretable function in which to put this) IF selectst.remember_pt <> state.pt THEN select_clear selectst selectst.remember_pt = state.pt FOR i as integer = LBOUND(menu) TO UBOUND(menu) menu_display(i) = menu(i) IF state.pt = i THEN menu_display(i) = highlight_menu_typing_selection_string(menu(i), selectst) END IF NEXT END SUB SUB highlight_menu_typing_selection(byref menu as BasicMenuItem vector, byref menu_display as BasicMenuItem vector, selectst as SelectTypeState, state as MenuState) 'Works with any type derived from BasicMenuItem. 'If the user moves the cursor normally, reset '(Yes, this is a rather regretable function in which to put this) IF selectst.remember_pt <> state.pt THEN select_clear selectst selectst.remember_pt = state.pt IF state.first <> 0 OR v_len(menu) - 1 <> state.last THEN showbug "highlight_menu_typing_selection: bad MenuState" v_copy menu_display, menu IF state.pt >= 0 ANDALSO state.pt < v_len(menu_display) THEN v_at(menu_display, state.pt)->text = highlight_menu_typing_selection_string(v_at(menu, state.pt)->text, selectst) END IF END SUB 'Internal function sometimes useful if a menu doesn't use a string array to store menu items 'Returns a modification of z with the part matching the search highlighted, using markup. FUNCTION highlight_menu_typing_selection_string(z as string, selectst as SelectTypeState) as string WITH selectst 'Initialisation IF .highlight_at = 0 THEN .highlight_at = 1 IF .query_at THEN .highlight_at = .query_at END IF IF .highlight_at > 0 AND LEN(.query) > 0 THEN 'Length of .query that matches the text DIM match_len as integer FOR i as integer = .highlight_at TO LEN(z) IF tolower(z[i - 1]) <> .query[i - .highlight_at] THEN EXIT FOR match_len += 1 NEXT 'In order to have something visible, highlight first character 'on a failed match... unless it's part of a markup tag IF match_len = 0 ANDALSO LEFT(z, 2) <> "${" THEN match_len = 1 'FIXME: should add a set of Custom-only UI colours instead of using these DIM col as integer IF .query_at THEN col = uilook(uiHighlight2) ELSE col = uilook(uiSelectedDisabled) END IF RETURN MID(z, 1, .highlight_at - 1) _ & bgcol_text(MID(z, .highlight_at, match_len), col) _ & MID(z, .highlight_at + match_len) END IF RETURN z END WITH END FUNCTION '========================================================================================== ' MenuSearch function family '========================================================================================== FUNCTION MenuSearcher.text(byval index as integer) as string IF this.menu_array THEN RETURN this.menu_array[index] ELSE RETURN v_at(this.menu_vector, index)->text END IF END FUNCTION FUNCTION MenuSearcher.selectable(byval index as integer) as bool IF this.menu_array THEN RETURN YES ELSE RETURN NOT v_at(this.menu_vector, index)->unselectable END IF END FUNCTION 'Possibilities for MenuSearcher.findfunc FUNCTION MenuSearcher_find_word_boundary(this as MenuSearcher, itemtext as string, query as string) as integer RETURN find_on_word_boundary_excluding(LCASE(itemtext), query, this.excludeword) END FUNCTION FUNCTION MenuSearcher_find_instr(this as MenuSearcher, itemtext as string, query as string) as integer RETURN INSTR(LCASE(itemtext), query) END FUNCTION 'End possibilities CONSTRUCTOR MenuSearcher(menu() as string) this.menu_array = @menu(0) END CONSTRUCTOR CONSTRUCTOR MenuSearcher(byval menu_vector as BasicMenuItem vector) this.menu_vector = menu_vector END CONSTRUCTOR 'Internal generic implementation for select_* functions SUB select_menuitem(searcher as MenuSearcher, selectst as SelectTypeState, state as MenuState) DIM index as integer = state.pt IF LEN(selectst.query) = 1 THEN loopvar index, state.first, state.last FOR ctr as integer = state.first TO state.last selectst.query_at = searcher.findfunc(searcher, searcher.text(index), selectst.query) IF selectst.query_at THEN 'index may be an unselectable menu item. However moving the cursor to a selectable item 'doesn't act nicely. Ideal solution would be to add and use selectst.highlight_pt, but 'that's too much work to bother, so just allow selecting unselectable items for now. 'WHILE NOT searcher.selectable(index) ' loopvar index, state.first, state.last 'WEND state.pt = index reset_menu_edit_state selectst.remember_pt = state.pt EXIT FOR END IF loopvar index, state.first, state.last NEXT END SUB 'Search menu() for a match to the typed query string using find_on_word_boundary_excluding. 'Use in combination with select_by_typing and optionally highlight_menu_typing_selection SUB select_on_word_boundary_excluding(menu() as string, selectst as SelectTypeState, state as MenuState, excludeword as string) DIM searcher as MenuSearcher = MenuSearcher(menu()) searcher.findfunc = @MenuSearcher_find_word_boundary searcher.excludeword = excludeword select_menuitem searcher, selectst, state END SUB SUB select_on_word_boundary(menu() as string, selectst as SelectTypeState, state as MenuState) select_on_word_boundary_excluding menu(), selectst, state, "" END SUB 'Works with any type derived from BasicMenuItem. SUB select_on_word_boundary_excluding(byval menu as BasicMenuItem vector, selectst as SelectTypeState, state as MenuState, excludeword as string) DIM searcher as MenuSearcher = MenuSearcher(menu) searcher.findfunc = @MenuSearcher_find_word_boundary searcher.excludeword = excludeword select_menuitem searcher, selectst, state END SUB SUB select_on_word_boundary(byval menu as BasicMenuItem vector, selectst as SelectTypeState, state as MenuState) select_on_word_boundary_excluding menu, selectst, state, "" END SUB 'Search menu() for a match to the typed query string using INSTR. 'Use in combination with select_by_typing and optionally highlight_menu_typing_selection SUB select_instr(menu() as string, selectst as SelectTypeState, state as MenuState) DIM searcher as MenuSearcher = MenuSearcher(menu()) searcher.findfunc = @MenuSearcher_find_instr select_menuitem searcher, selectst, state END SUB '========================================================================================== ' MenuDef '========================================================================================== FUNCTION MenuDefItem.visible() as bool RETURN disabled = NO ORELSE hide_if_disabled = NO ORELSE override_hide = YES END FUNCTION CONSTRUCTOR MenuDef() dlist_construct itemlist, OFFSETOF(MenuDefItem, trueorder) END CONSTRUCTOR DESTRUCTOR MenuDef() DeleteMenuItems this END DESTRUCTOR SUB ClearMenuData(menu as MenuDef) menu.Destructor() menu.Constructor() END SUB 'Initialise a MenuDef so that it looks like a plain menu, as drawn by standardmenu SUB InitLikeStandardMenu(menu as MenuDef) ClearMenuData menu WITH menu .no_box = YES .textalign = alignLeft .alignhoriz = alignLeft .alignvert = alignTop .anchorhoriz = alignLeft .anchorvert = alignTop .min_chars = 9999 'Fill screen .bordersize = -pMenuX 'Draw at 4,4 .withtags = YES END WITH END SUB SUB DeleteMenuItems(menu as MenuDef) DIM i as integer WITH menu FOR i = 0 TO .numitems - 1 dlist_remove menu.itemlist, .items[i] DELETE .items[i] NEXT i DEALLOCATE(.items) .items = NULL END WITH END SUB 'recreate a menu's items[] array, which sorts visible items to the top SUB SortMenuItems(menu as MenuDef) DIM as integer i, j, lowest, found DIM mi as MenuDefItem ptr IF menu.numitems = 0 THEN DEALLOCATE(menu.items) menu.items = NULL EXIT SUB END IF menu.items = REALLOCATE(menu.items, SIZEOF(any ptr) * menu.numitems) 'stick all visible items in .items[] i = 0 mi = menu.first WHILE mi IF mi->visible THEN menu.items[i] = mi i += 1 END IF mi = mi->trueorder.next WEND 'append all invisible items mi = menu.first WHILE mi IF NOT mi->visible THEN menu.items[i] = mi i += 1 END IF mi = mi->trueorder.next WEND END SUB FUNCTION getmenuname(byval record as integer) as string DIM as string ret #IFDEF IS_GAME STATIC cache(32) as IntStrPair ret = search_string_cache(cache(), record, game_unique_id) IF ret <> "" THEN RETURN ret #ENDIF DIM menu_set as MenuSet menu_set.menufile = workingdir + SLASH + "menus.bin" menu_set.itemfile = workingdir + SLASH + "menuitem.bin" DIM menu as MenuDef LoadMenuData menu_set, menu, record, YES ret = menu.name #IFDEF IS_GAME add_string_cache cache(), record, ret #ENDIF RETURN ret END FUNCTION '(Re-)initialise menu state, preserving .pt if valid SUB init_menu_state (byref state as MenuState, menu() as string, menuopts as MenuOptions) WITH state IF .size = 0 THEN .autosize = YES DIM position_was_known as bool = .position_known 'Position not known before it's actually drawn, fill with dummy data for now calc_menu_rect state, menuopts, .rect.xy .position_known = position_was_known .first = LBOUND(menu) .last = UBOUND(menu) END WITH ' Bound .pt and update .top correct_menu_state state END SUB '(Re-)initialise menu state, preserving .pt if valid or otherwise picking a new one. 'Also sorts the menu. SUB init_menu_state (byref state as MenuState, menu as MenuDef) WITH state .first = 0 .last = count_visible_menu_items(menu) - 1 'Compute .size, assuming vpage calc_menustate_size state, menu, vpage END WITH ' Pick a suitable .pt sort_menu_and_select_selectable_item menu, state ' Update .top correct_menu_state_top state END SUB ' Make sure .pt is valid, move it to the next selectable menu item if the current one is hidden/unselectable. ' This only works correctly if you haven't already called SortMenuItems! ' Does not update .top (not a substitute for correct_menu_state). ' Note that this is a substitue for what usemenu does, but is specific to MenuDef's ' troublesome shuffling of hidden of menu items to the end. SUB sort_menu_and_select_selectable_item(menu as MenuDef, state as MenuState) WITH menu DIM selecteditem as MenuDefItem ptr IF state.pt >= 0 AND state.pt < .numitems THEN selecteditem = .items[state.pt] ELSE selecteditem = NULL END IF SortMenuItems menu ' First forwards look for the next visible and selectable item WHILE selecteditem ANDALSO (NOT selecteditem->visible OR selecteditem->unselectable) selecteditem = selecteditem->trueorder.next WEND IF selecteditem THEN FOR i as integer = 0 TO .numitems - 1 IF .items[i] = selecteditem THEN state.pt = i EXIT SUB END IF NEXT i END IF ' otherwise pick the last visible and selectable one FOR i as integer = .numitems - 1 TO 0 STEP -1 IF .items[i]->visible AND NOT .items[i]->unselectable THEN state.pt = i EXIT SUB END IF NEXT ' The menu has no selectable items state.pt = -1 END WITH END SUB ' Returns index in menu.items[] FUNCTION append_menu_item(byref menu as MenuDef, caption as zstring ptr, byval t as integer=mtypeLabel, byval sub_t as integer=0, byval dataptr as any ptr=0) as integer DIM i as integer DIM item as MenuDefItem ptr item = NEW MenuDefItem WITH *item .caption = *caption .t = t .sub_t = sub_t .dataptr = dataptr END WITH dlist_append(menu.itemlist, item) 'updates .numitems 'rather than call SortMenuItems, shuffle hidden items down a slot and insert new item menu.items = REALLOCATE(menu.items, menu.numitems * SIZEOF(any ptr)) FOR i = menu.numitems - 2 TO 0 STEP -1 'last item in array is garbage IF menu.items[i]->visible = NO THEN SWAP menu.items[i], menu.items[i + 1] ELSE EXIT FOR END IF NEXT menu.items[i + 1] = item RETURN menu.numitems - 1 END FUNCTION DESTRUCTOR MenuDefItem() v_free extravec END DESTRUCTOR SUB remove_menu_item(byref menu as MenuDef, byval mi as MenuDefItem ptr) dlist_remove menu.itemlist, mi DELETE mi 'rebuild menu.items[] SortMenuItems menu END SUB SUB remove_menu_item(byref menu as MenuDef, byval mislot as integer) remove_menu_item menu, menu.items[mislot] END SUB SUB swap_menu_items(byref menu1 as MenuDef, byval mislot1 as integer, byref menu2 as MenuDef, byval mislot2 as integer) dlist_swap(menu1.itemlist, menu1.items[mislot1], menu2.itemlist, menu2.items[mislot2]) SortMenuItems menu1 SortMenuItems menu2 END SUB PROPERTY MenuDefItem.extra(index as integer) as integer RETURN get_extra(extravec, index) END PROPERTY PROPERTY MenuDefItem.extra(index as integer, newval as integer) set_extra(extravec, index, newval) END PROPERTY '========================================================================================== ' Saving/Loading/(De)serializing MenuDefs '========================================================================================== SUB LoadMenuData(menu_set as MenuSet, dat as MenuDef, byval record as integer, byval ignore_items as integer=NO) ClearMenuData dat IF record > gen(genMaxMenu) OR record < 0 THEN EXIT SUB DIM f as integer OPENFILE(menu_set.menufile, FOR_BINARY + ACCESS_READ, f) SEEK #f, record * getbinsize(binMENUS) + 1 WITH dat .record = record .name = ReadByteStr(f, 20) .age = 0 .boxstyle = ReadShort(f) .textcolor = ReadShort(f) .maxrows = ReadShort(f) DIM bits(0) as integer bits(0) = ReadShort(f) MenuBitsFromArray dat, bits() .offset.x = ReadShort(f) .offset.y = ReadShort(f) .alignhoriz = alignCenter ' Not saved .alignvert = alignCenter ' ditto .anchorhoriz = ReadShort(f) + 1 ' On-disk enum is -1,0,1 .anchorvert = ReadShort(f) + 1 ' ditto .textalign = ReadShort(f) + 1 ' ditto .min_chars = ReadShort(f) .max_chars = ReadShort(f) .bordersize = ReadShort(f) .on_close = ReadShort(f) .esc_menu = ReadShort(f) ReadShort(f) 'garbage INT .itemspacing = ReadShort(f) .disabled_textcolor = ReadShort(f) END WITH lazyclose f IF ignore_items = NO THEN 'This is disableable for performance when all you care about loading is the menu's name LoadMenuItems menu_set, dat, record END IF END SUB SUB LoadMenuItems(menu_set as MenuSet, menu as MenuDef, byval record as integer) DIM i as integer DIM f as integer DIM member as integer DIM actual_record_count as integer = 0 'The items may appear out-of-order in menuitem.bin, so rather than just append them as 'we find the, first we store them in this temp array: REDIM itemarray(0) as MenuDefItem ptr OPENFILE(menu_set.itemfile, FOR_BINARY + ACCESS_READ, f) 'FIXME: this shouldn't be here, it's covered in upgrade() (but commented out currently) actual_record_count = LOF(f) / getbinsize(binMENUITEM) IF actual_record_count <> gen(genMaxMenuItem) + 1 THEN debug "menuitem.bin record count sanity check failed " & gen(genMaxMenuItem) & "->" & actual_record_count - 1 gen(genMaxMenuItem) = actual_record_count - 1 END IF FOR i = 0 TO gen(genMaxMenuItem) SEEK #f, i * getbinsize(binMENUITEM) + 1 member = ReadShort(f) IF member = record + 1 THEN LoadMenuItem f, itemarray(), i END IF NEXT i lazyclose f 'build the item list FOR i = 0 TO UBOUND(itemarray) IF itemarray(i) <> NULL THEN dlist_append(menu.itemlist, itemarray(i)) ELSE 'can't create a zero length FB array IF UBOUND(itemarray) <> 0 THEN debug "menu " & record & " item " & i & " could not be found in " & menu_set.itemfile END IF END IF NEXT 'build the items[] array SortMenuItems menu END SUB SUB LoadMenuItem(byval f as integer, items() as MenuDefItem ptr, byval record as integer) DIM i as integer DIM bits(0) as integer DIM mi as MenuDefItem ptr DIM itemnum as integer mi = NEW MenuDefItem SEEK #f, record * getbinsize(binMENUITEM) + 1 WITH *mi ReadShort(f) 'throw away member .caption = ReadByteStr(f, 38) itemnum = ReadShort(f) .t = ReadShort(f) .sub_t = ReadShort(f) .tag1 = ReadShort(f) .tag2 = ReadShort(f) .settag = ReadShort(f) .togtag = ReadShort(f) bits(0) = ReadShort(f) FOR i = 0 TO 2 'Don't initialize the extra data vector unless needed DIM temp as integer = ReadShort(f) IF temp THEN .extra(i) = temp NEXT i .col = ReadShort(f) .disabled_col = ReadShort(f) END WITH IF itemnum > UBOUND(items) THEN REDIM PRESERVE items(itemnum) items(itemnum) = mi MenuItemBitsFromArray *mi, bits() END SUB SUB SaveMenuData(menu_set as MenuSet, dat as MenuDef, byval record as integer) DIM f as integer DIM bits(0) as integer OPENFILE(menu_set.menufile, FOR_BINARY, f) SEEK #f, record * getbinsize(binMENUS) + 1 WITH dat WriteByteStr(f, 20, .name) WriteShort(f, -1, .boxstyle) WriteShort(f, -1, .textcolor) WriteShort(f, -1, .maxrows) MenuBitsToArray dat, bits() WriteShort(f, -1, bits(0)) WriteShort(f, -1, .offset.x) WriteShort(f, -1, .offset.y) WriteShort(f, -1, .anchorhoriz - 1) ' On-disk enum is -1,0,1 WriteShort(f, -1, .anchorvert - 1) ' ditto WriteShort(f, -1, .textalign - 1) ' ditto WriteShort(f, -1, .min_chars) WriteShort(f, -1, .max_chars) WriteShort(f, -1, .bordersize) WriteShort(f, -1, .on_close) WriteShort(f, -1, .esc_menu) WriteShort(f, -1, 0) 'wasted garbage INT WriteShort(f, -1, .itemspacing) WriteShort(f, -1, .disabled_textcolor) END WITH CLOSE #f SaveMenuItems menu_set, dat, record END SUB SUB SaveMenuItems(menu_set as MenuSet, menu as MenuDef, byval record as integer) DIM i as integer DIM f as integer DIM member as integer DIM elem as integer = 0 DIM mi as MenuDefItem ptr DIM blankmi as MenuDefItem OPENFILE(menu_set.itemfile, FOR_BINARY, f) 'Loop through each record and orphan all old entries for this menu FOR i = 0 TO gen(genMaxMenuItem) SEEK #f, i * getbinsize(binMENUITEM) + 1 member = ReadShort(f) IF member = record + 1 THEN SaveMenuItem f, blankmi, i, -1, 0 END IF NEXT i 'Loop through each record, writing new values into orphan slots mi = menu.first FOR i = 0 TO gen(genMaxMenuItem) SEEK #f, i * getbinsize(binMENUITEM) + 1 member = ReadShort(f) IF member = 0 THEN IF mi = NULL THEN EXIT FOR SaveMenuItem f, *mi, i, record, elem elem = elem + 1 mi = mi->trueorder.next END IF NEXT i DO WHILE mi 'More items need to be written, append them gen(genMaxMenuItem) += 1 SaveMenuItem f, *mi, gen(genMaxMenuItem), record, elem elem += 1 mi = mi->trueorder.next LOOP CLOSE #f END SUB SUB SaveMenuItem(byval f as integer, mi as MenuDefItem, byval record as integer, byval menunum as integer, byval itemnum as integer) DIM i as integer DIM bits(0) as integer SEEK #f, record * getbinsize(binMENUITEM) + 1 WITH mi WriteShort(f, -1, menunum + 1) WriteByteStr(f, 38, .caption) WriteShort(f, -1, itemnum) WriteShort(f, -1, .t) WriteShort(f, -1, .sub_t) WriteShort(f, -1, .tag1) WriteShort(f, -1, .tag2) WriteShort(f, -1, .settag) WriteShort(f, -1, .togtag) MenuItemBitsToArray mi, bits() WriteShort(f, -1, bits(0)) FOR i = 0 TO 2 WriteShort(f, -1, .extra(i)) NEXT i WriteShort(f, -1, .col) WriteShort(f, -1, .disabled_col) END WITH END SUB SUB MenuBitsToArray (menu as MenuDef, bits() as integer) bits(0) = 0 WITH menu setbit bits(), 0, 0, .translucent setbit bits(), 0, 1, .no_scrollbar setbit bits(), 0, 2, .allow_gameplay setbit bits(), 0, 3, .suspend_player setbit bits(), 0, 4, .no_box setbit bits(), 0, 5, .no_close setbit bits(), 0, 6, .no_controls setbit bits(), 0, 7, .prevent_main_menu setbit bits(), 0, 8, .advance_textbox setbit bits(), 0, 9, .highlight_selection setbit bits(), 0, 10, .remember_selection setbit bits(), 0, 11, .clamp_to_screen END WITH END SUB SUB MenuBitsFromArray (menu as MenuDef, bits() as integer) WITH menu .translucent = xreadbit(bits(), 0) .no_scrollbar = xreadbit(bits(), 1) .allow_gameplay = xreadbit(bits(), 2) .suspend_player = xreadbit(bits(), 3) .no_box = xreadbit(bits(), 4) .no_close = xreadbit(bits(), 5) .no_controls = xreadbit(bits(), 6) .prevent_main_menu = xreadbit(bits(), 7) .advance_textbox = xreadbit(bits(), 8) .highlight_selection = xreadbit(bits(), 9) .remember_selection = xreadbit(bits(), 10) .clamp_to_screen = xreadbit(bits(), 11) END WITH END SUB SUB MenuItemBitsToArray (mi as MenuDefItem, bits() as integer) bits(0) = 0 WITH mi setbit bits(), 0, 0, .hide_if_disabled setbit bits(), 0, 1, .close_when_activated setbit bits(), 0, 2, .skip_close_script END WITH END SUB SUB MenuItemBitsFromArray (mi as MenuDefItem, bits() as integer) WITH mi .hide_if_disabled = xreadbit(bits(), 0) .close_when_activated = xreadbit(bits(), 1) .skip_close_script = xreadbit(bits(), 2) END WITH END SUB FUNCTION read_menu_int (menu as MenuDef, byval intoffset_plus1 as integer) as integer '--This function allows read access to integers in a menu for the plotscripting interface '--intoffset_plus1 is the integer offset as appears in the MENUS.BIN lump documentation plus one DIM bits(0) as integer WITH menu SELECT CASE intoffset_plus1 CASE 12: RETURN .boxstyle CASE 13: RETURN .textcolor CASE 14: RETURN .maxrows CASE 15: MenuBitsToArray menu, bits() RETURN bits(0) CASE 16: RETURN .offset.x CASE 17: RETURN .offset.y CASE 18: RETURN .anchorhoriz - 1 ' Translate to align: constants CASE 19: RETURN .anchorvert - 1 ' ditto CASE 20: RETURN .textalign - 1 ' ditto CASE 21: RETURN .min_chars CASE 22: RETURN .max_chars CASE 23: RETURN .bordersize CASE 24: RETURN .on_close CASE 25: RETURN .esc_menu '26 is garbage CASE 27: RETURN .itemspacing CASE 28: RETURN .disabled_textcolor CASE ELSE reporterr "read_menu_int: " & intoffset_plus1 & " is an invalid integer offset" END SELECT END WITH RETURN 0 END FUNCTION SUB write_menu_int (menu as MenuDef, byval intoffset_plus1 as integer, byval n as integer) '--This sub allows write access to integers in a menu for the plotscripting interface '--FIXME: there's no error checking, not even in the wrapper scripts in plotscr.hsd! '--intoffset_plus1 is the integer offset as appears in the MENUS.BIN lump documentation plus one DIM bits(0) as integer WITH menu SELECT CASE intoffset_plus1 CASE 12: .boxstyle = n CASE 13: .textcolor = n CASE 14: .maxrows = n CASE 15: bits(0) = n MenuBitsFromArray menu, bits() CASE 16: .offset.x = n CASE 17: .offset.y = n CASE 18: .anchorhoriz = n + 1 ' Translate from align: constants CASE 19: .anchorvert = n + 1 ' ditto CASE 20: .textalign = n + 1 ' ditto CASE 21: .min_chars = n CASE 22: .max_chars = n CASE 23: .bordersize = n CASE 24: .on_close = n CASE 25: .esc_menu = n '26 is garbage CASE 27: .itemspacing = n CASE 28: .disabled_textcolor = n CASE ELSE reporterr "write_menu_int: " & intoffset_plus1 & " is an invalid integer offset" END SELECT END WITH END SUB FUNCTION read_menu_item_int (mi as MenuDefItem, byval intoffset as integer) as integer '--This function allows read access to integers in a menu item for the plotscripting interface '--intoffset is the integer offset, same as appears in the MENUITEM.BIN lump documentation DIM bits(0) as integer WITH mi SELECT CASE intoffset CASE 22: RETURN .t CASE 23: RETURN .sub_t CASE 24: RETURN .tag1 CASE 25: RETURN .tag2 CASE 26: RETURN .settag CASE 27: RETURN .togtag CASE 28: MenuItemBitsToArray mi, bits() RETURN bits(0) CASE 29 TO 31: RETURN .extra(intoffset - 29) CASE 32: RETURN .col CASE 33: RETURN .disabled_col CASE ELSE reporterr "read_menu_item_int: " & intoffset & " is an invalid integer offset" END SELECT END WITH RETURN 0 END FUNCTION SUB write_menu_item_int (mi as MenuDefItem, byval intoffset as integer, byval n as integer) '--This sub allows write access to integers in a menu item for the plotscripting interface '--intoffset is the integer offset, same as appears in the MENUITEM.BIN lump documentation DIM bits(0) as integer WITH mi SELECT CASE intoffset CASE 22: .t = n CASE 23: .sub_t = n CASE 24: .tag1 = n CASE 25: .tag2 = n CASE 26: .settag = n CASE 27: .togtag = n CASE 28: bits(0) = n MenuItemBitsFromArray mi, bits() CASE 29 TO 31: .extra(intoffset - 29) = n CASE 32: .col = n CASE 33: .disabled_col = n CASE ELSE reporterr "write_menu_item_int: " & intoffset & " is an invalid integer offset" END SELECT END WITH END SUB '========================================================================================== ' Drawing MenuDefs '========================================================================================== 'Update the caption of each menu item 'Needs to be called before calc_menu_rect SUB update_menu_captions (menu as MenuDef) FOR i as integer = 0 TO menu.numitems - 1 menu.items[i]->text = get_menu_item_caption(*menu.items[i], menu) NEXT END SUB SUB draw_menu (menu as MenuDef, state as MenuState, byval page as integer) DIM i as integer DIM elem as integer DIM where as XYPair menu.age += 1 update_menu_captions menu calc_menu_rect state, menu, page DIM bord as integer = 8 + menu.bordersize IF menu.no_box = NO THEN edgeboxstyle menu.rect, menu.boxstyle, page, menu.translucent, menu.suppress_borders END IF state.tog = state.tog XOR 1 'First draw the highlight rectangle, if any, behind the items IF menu.highlight_selection THEN i = state.pt - state.top IF i >= 0 AND i <= state.size THEN where = menu.rect.topleft + XY(4, bord + i * state.spacing - menu.itemspacing \ 2) rectangle where.x, where.y, menu.rect.wide - 8, state.spacing, uiLook(uiHighlight), page END IF END IF IF menu.no_scrollbar = NO THEN draw_scrollbar state, menu, page END IF 'Draw the items FOR i = 0 TO state.size elem = state.top + i IF elem >= 0 AND elem < menu.numitems THEN WITH *menu.items[elem] DIM col as integer col = menu_item_color(state, elem, .disabled, .unselectable, .col, .disabled_col, menu.textcolor, menu.disabled_textcolor) IF .visible THEN DIM itemrect as RectType = menudef_item_rect(menu, state, .text, elem) IF menu.game_menu ANDALSO .t = mtypeSpecial THEN ' Check for menu items with bars behind. The bar is drawn using the menu's boxstyle and Line border DIM bar_width as integer = 0 DIM metermax as integer metermax = small(state.rect.wide, 80) 'large(48, textwidth(.text)) IF .sub_t = spMusicVolume OR .sub_t = spVolumeMenu THEN bar_width = get_music_volume() * metermax ELSEIF .sub_t = spSoundVolume THEN bar_width = get_global_sfx_volume() * metermax ELSEIF .sub_t = spMargins THEN ' TV Safe Margin meter bar_width = get_safe_zone_margin() * metermax \ 10 END IF IF bar_width THEN edgeboxstyle menu.rect.x + (menu.rect.wide - metermax) \ 2, itemrect.y, bar_width, 10, menu.boxstyle, page, NO, YES END IF END IF edgeprint .text, itemrect.x, itemrect.y, col, page, menu.withtags END IF END WITH END IF NEXT i END SUB ' Calculate the on-screen position and size for a menu item (excluding volume bars or anything like that). ' (See also standardmenu_item_rect) FUNCTION menudef_item_rect (menu as MenuDef, state as MenuState, menutext as string, index as integer) as RectType DIM ret as RectType ret.wh = textsize(menutext) 'Adding bord to menu.rect like this should equal state.rect. TODO: use state.rect instead? DIM bord as integer bord = 8 + menu.bordersize WITH menu.rect SELECT CASE menu.textalign CASE alignLeft ret.x = .x + bord CASE alignCenter ret.x = .x + (.wide - ret.w) / 2 CASE alignRight ret.x = .x + .wide - bord - ret.w END SELECT ret.y = .y + bord + ((index - state.top) * (10 + menu.itemspacing)) END WITH RETURN ret END FUNCTION ' Calculate state.size of a MenuDef menu from menu.maxrows ' .maxrows=0 is the MenuDef equivalent of state.autosize (which isn't used for MenuDef) SUB calc_menustate_size (byref state as MenuState, menu as MenuDef, page as integer) WITH state .spacing = 10 + menu.itemspacing IF menu.maxrows <= 0 THEN ' Calculate available vertical space DIM vertspace as integer IF menu.clamp_to_screen THEN ' Doesn't matter how the menu is aligned vertspace = vpages(page)->h ELSE ' The anchor point on the screen. anchory is how much space is on the screen ' above the anchor point, vpages(page)->h - anchory is the space below. DIM anchory as integer = anchor_point(menu.alignvert, vpages(page)->h) + menu.offset.y SELECT CASE menu.anchorvert 'Anchor on the menu rect CASE alignTop: vertspace = vpages(page)->h - anchory CASE alignBottom: vertspace = anchory CASE alignCenter: vertspace = 2 * small(anchory, vpages(page)->h - anchory) END SELECT END IF DIM bord as integer = 8 + menu.bordersize .size = large(1, (vertspace - 2 * bord + menu.itemspacing) \ .spacing) - 1 ELSE .size = menu.maxrows - 1 END IF .size = small(.size, .last) END WITH END SUB ' Calculate menu.rect and state.rect (which includes the border padding) and also state.size SUB calc_menu_rect (state as MenuState, menu as MenuDef, byval page as integer) DIM i as integer DIM bord as integer bord = 8 + menu.bordersize 'Compute total width & height of all the items and borders menu.rect.wide = bord * 2 menu.rect.high = bord * 2 FOR i = 0 TO menu.numitems - 1 WITH *menu.items[i] 'hidden items used to matter for auto-width but not auto-height; now they don't for either IF .visible = NO THEN CONTINUE FOR menu.rect.wide = large(menu.rect.wide, textwidth(.text) + bord * 2) menu.rect.high += 10 IF i <> 0 THEN menu.rect.high += menu.itemspacing END WITH NEXT i 'Enforce min width menu.rect.wide = large(menu.rect.wide, menu.min_chars * 8 + bord * 2) 'Limit to screen size (this isn't strictly needed, and maxrows=0 also does the same) 'menu.rect.wide = small(menu.rect.wide, vpages(page)->w) menu.rect.high = small(menu.rect.high, vpages(page)->h) 'Set state.size and state.spacing calc_menustate_size state, menu, page menu.rect.high = small(menu.rect.high, (state.size + 1) * state.spacing - menu.itemspacing + bord * 2) WITH menu .rect.x = anchor_point(.alignhoriz, vpages(page)->w) - anchor_point(.anchorhoriz, .rect.wide) + .offset.x .rect.y = anchor_point(.alignvert, vpages(page)->h) - anchor_point(.anchorvert, .rect.high) + .offset.y IF .clamp_to_screen THEN 'Clamping shouldn't happen when .maxrows=0 'Keep the menu 1 pixel away from the screen edge, looks better. 'Not really worth making this customisable. DIM padding as XYPair = XY(1, 1) 'MenuDef doesn't support RelPos, but we can reuse this function .rect.xy = relative_pos(.rect.xy + XY(showLeft, showTop) - padding, vpages(page)->size, .rect.wh + padding * 2) + padding END IF END WITH WITH state .position_known = YES .rect.x = menu.rect.x + bord .rect.y = menu.rect.y + bord .rect.wide = menu.rect.wide - bord * 2 .rect.high = menu.rect.high - bord * 2 END WITH END SUB FUNCTION anchor_point(byval anchor as AlignType, byval size as integer) as integer SELECT CASE anchor CASE alignLeft RETURN 0 CASE alignMiddle RETURN size \ 2 CASE alignRight RETURN size END SELECT END FUNCTION 'Number visible items -- menu.numitems is total number. FUNCTION count_visible_menu_items (menu as MenuDef) as integer DIM i as integer DIM count as integer = 0 FOR i = 0 TO menu.numitems - 1 WITH *menu.items[i] IF .visible = NO THEN CONTINUE FOR count += 1 END WITH NEXT i RETURN count END FUNCTION FUNCTION get_menu_item_caption (mi as MenuDefItem, menu as MenuDef) as string DIM cap as string cap = mi.caption IF menu.game_menu ANDALSO LEN(cap) = 0 THEN 'No caption, use the default SELECT CASE mi.t CASE mtypeSpecial cap = get_special_menu_caption(mi.sub_t) CASE mtypeMenu cap = getmenuname(mi.sub_t) IF cap = "" THEN cap = "Menu " & mi.sub_t CASE mtypeTextBox cap = "Text Box " & mi.sub_t CASE mtypeScript cap = scriptname(mi.sub_t) END SELECT END IF IF menu.edit_mode = YES THEN IF LEN(TRIM(cap)) = 0 THEN cap = "[BLANK]" IF mi.disabled ANDALSO mi.hide_if_disabled THEN cap &= " [hidden]" cap &= get_menu_item_editing_annotation(mi) END IF #IFDEF IS_GAME embedtext cap #ENDIF IF menu.max_chars > 0 THEN ' Crop overlength 'TODO: we should replace max_chars with a pixel width, but in the meantime, special-case for withtags IF menu.withtags THEN IF menu.textalign = alignRight THEN cap = text_right(cap, menu.max_chars * 8, NO, NO) 'ellipsis=NO, withtags=NO ELSE ' left and center align cap = text_left(cap, menu.max_chars * 8, NO, NO) 'ellipsis=NO, withtags=NO END IF ELSE IF menu.textalign = alignRight THEN cap = RIGHT(cap, menu.max_chars) ELSE ' left and center align cap = LEFT(cap, menu.max_chars) END IF END IF END IF RETURN cap END FUNCTION FUNCTION get_special_menu_caption(byval subtype as integer) as string DIM cap as string SELECT CASE subtype CASE spItems : cap = readglobalstring(60, "Items", 10) CASE spSpells : cap = readglobalstring(61, "Spells", 10) CASE spStatus : cap = readglobalstring(62, "Status", 10) CASE spEquip : cap = readglobalstring(63, "Equip", 10) CASE spOrder : cap = readglobalstring(64, "Order", 10) CASE spTeam : cap = readglobalstring(65, "Team", 10) CASE spTeamOrOrder IF prefbit(5) THEN '"Hero Swapping Always Available" cap = readglobalstring(65, "Team", 10) ELSE cap = readglobalstring(64, "Order", 10) END IF CASE spMap,spMapMaybe : cap = readglobalstring(68, "Map", 10) CASE spSave,spSaveMaybe: cap = readglobalstring(66, "Save", 10) CASE spLoad : cap = readglobalstring(322, "Load", 20) CASE spQuit : cap = readglobalstring(67, "Quit", 10) CASE spVolumeMenu : cap = readglobalstring(69, "Volume", 10) CASE spMusicVolume : cap = readglobalstring(318, "Music", 20) CASE spSoundVolume : cap = readglobalstring(320, "Sound", 20) CASE spMargins : cap = readglobalstring(308, "Margins", 10) CASE spPurchases : cap = readglobalstring(313, "Purchases", 10) CASE spWindowed : cap = readglobalstring(314, "Windowed", 20) CASE spFullscreen : cap = readglobalstring(316, "Fullscreen", 20) END SELECT RETURN cap END FUNCTION ' Return the suffix that is appended to a menu item while in edit mode FUNCTION get_menu_item_editing_annotation (mi as MenuDefItem) as string SELECT CASE mi.t CASE 1 ' special screen SELECT CASE mi.sub_t CASE spTeamOrOrder RETURN " [general bitset]" CASE spMapMaybe RETURN " [if allowed by map]" CASE spSaveMaybe RETURN " [if allowed by map]" CASE spMargins RETURN " [if available]" CASE spPurchases RETURN " [if available]" CASE spWindowed RETURN " [if fullscreen]" CASE spFullscreen RETURN " [if windowed]" CASE spVolumeMenu RETURN " [menu]" END SELECT END SELECT RETURN "" END FUNCTION 'Whether, according to the in-game meaning of the type and subtype, 'this menu item can be activated (possibly setting tags and closing the menu) FUNCTION menu_item_is_activatable(mi as MenuDefItem) as bool IF mi.t = mtypeLabel ANDALSO (mi.sub_t = lbDisabled OR mi.sub_t = lbUnselectable) THEN RETURN NO ELSEIF mi.t = mtypeSpecial ANDALSO (mi.sub_t = spMusicVolume OR mi.sub_t = spSoundVolume OR mi.sub_t = spMargins) THEN 'These menu items have bars, and it doesn't make sense to activate them RETURN NO ELSE RETURN YES END IF END FUNCTION '========================================================================================== ' Scrollbars! '========================================================================================== 'Whether this menu will be drawn with a scrollbar (because it's larger than 'its .size), if not explicitly disabled (by MenuDef/MenuOptions.no_scrollbar) FUNCTION MenuState.would_have_scrollbar() as bool DIM count as integer = last - first + 1 'recall size is off-by-1 RETURN (top > first OR count > (size + 1)) ANDALSO count > 0 END FUNCTION SUB draw_scrollbar(state as MenuState, menu as MenuDef, page as integer, align as AlignType = alignRight) draw_scrollbar state, menu.rect, menu.boxstyle, page, align END SUB 'Hint: usually you would pass state.rect as rect 'count being the number of (visible) menu items 'align is which side to put the slider on SUB draw_scrollbar(state as MenuState, rect as RectType, boxstyle as integer=0, page as integer, align as AlignType = alignRight) DIM count as integer = state.last - state.first + 1 'recall state.size is off-by-1 IF state.would_have_scrollbar() THEN IF count > 0 THEN DIM sbar as RectType DIM slider as RectType ' 2px padding on each side, 4px wide slider DIM pad as integer = 2 sbar.wide = 4 sbar.high = rect.high - pad * 2 sbar.x = rect.x + pad + anchor_point(align, rect.wide - pad * 2 - sbar.wide) sbar.y = rect.y + pad WITH sbar slider.y = .high / count * (state.top - state.first) slider.high = large(1, .high / count * (state.size + 1)) rectangle .x, .y, .wide, .high, uilook(uiBackground), page rectangle .x, .y + slider.y, .wide, slider.high, boxlook(boxstyle).edgecol, page END WITH END IF END IF END SUB SUB draw_fullscreen_scrollbar(state as MenuState, boxstyle as integer=0, page as integer, align as AlignType = alignRight) DIM rect as RectType rect.wide = vpages(page)->w rect.high = vpages(page)->h draw_scrollbar state, rect, boxstyle, page, align END SUB '========================================================================================== ' ModularMenu '========================================================================================== ' The boilerplate for displaying a MenuDef menu, if nothing else has to be drawn to the screen. SUB run_MenuDef(menu as MenuDef, each_tick as FnMenuLogic, dataptr as any ptr = NULL) DIM holdscreen as integer = allocatepage copypage vpage, holdscreen DIM state as MenuState init_menu_state state, menu DO setwait 55 setkeys YES usemenu state IF keyval(ccCancel) > 1 THEN EXIT DO IF each_tick(menu, state, dataptr) THEN EXIT DO copypage holdscreen, vpage draw_menu menu, state, vpage setvispage vpage dowait LOOP setkeys freepage holdscreen END SUB SUB ModularMenu.add_item(itemtype as integer = 0, id as integer = -1, text as string = "", canselect as bool = YES, heading as bool = NO, disabled as bool = NO) a_append itemtypes(), itemtype a_append itemids(), id IF heading THEN a_append menu(), fgtag(uilook(eduiHeading)) & text ELSE a_append menu(), text END IF a_append selectable(), canselect IF canselect = NO THEN use_selectable = YES a_append shaded(), disabled END SUB ' This is a kludge: need to add support for multiline items to standardmenu. SUB ModularMenu.add_wrapped_items(itemtype as integer = 0, id as integer = -1, text as string = "", canselect as bool = YES, heading as bool = NO, disabled as bool = NO) DIM screenwidth as integer = (vpages(vpage)->w - 16) \ 8 DIM lines() as string split(wordwrap(text, screenwidth), lines()) FOR idx as integer = 0 TO UBOUND(lines) add_item itemtype, id, lines(idx), canselect, heading, disabled NEXT END SUB SUB ModularMenu.add_spacer() add_item , , , NO, YES, NO END SUB SUB ModularMenu.header(text as string) add_spacer add_item , , text, NO, YES, NO END SUB SUB ModularMenu.clear_menu() ERASE menu ERASE selectable ERASE shaded ERASE itemtypes ERASE itemids state.last = -1 END SUB SUB ModularMenu.update() END SUB FUNCTION ModularMenu.each_tick() as bool RETURN NO END FUNCTION FUNCTION ModularMenu.try_exit() as bool RETURN YES END FUNCTION SUB ModularMenu.draw_underlays() END SUB SUB ModularMenu.draw_overlays() END SUB SUB ModularMenu.draw() draw_underlays() DIM titlesize as XYPair DIM where as XYPair = (pMenuX, pMenuY) IF floating THEN 'state.rect wouldn't be calculated until standardmenu is called, so need 'to pre-calculate to prevent flicker.... 'TODO: In fact, this is a pretty ugly way to draw a floating menu. 'So this should probably be replaced with MenuDef draw_menu DIM basicmenu as BasicMenuItem vector standard_to_basic_menu menu(), state, basicmenu where = XY(pCentered, pCentered) IF LEN(title) THEN titlesize = textsize(title, rWidth - 20, fontEdged) where.y += 8 + titlesize.h \ 2 state.autosize_ignore_pixels = 14 + titlesize.h END IF calc_menu_rect state, menuopts, where, vpage, basicmenu v_free basicmenu edgeboxstyle where.x, where.y, state.rect.wide + 10, state.rect.high + 10, 1, vpage END IF IF LEN(title) THEN IF floating THEN DIM titley as integer = where.y - state.rect.high \ 2 - titlesize.h - 8 centerbox rCenter, titley + (titlesize.h + 6) \ 2, titlesize.w + 12, titlesize.h + 6, 1, vpage wrapprint title, pCentered, titley, uilook(uiText), vpage, rWidth - 20 'where.y += titlesize.h + 2 ELSE wrapprint title, where.x, where.y, uilook(uiText), vpage, rWidth - where.x - 4 where.y += textsize(title, rWidth - where.x).h + 4 END IF END IF IF UBOUND(menu) >= 0 THEN DIM menu_display(UBOUND(menu)) as string highlight_menu_typing_selection menu(), menu_display(), selectst, state standardmenu menu_display(), state, shaded(), where.x, where.y, vpage, menuopts END IF IF LEN(tooltip) THEN wrapprintbg tooltip, pInfoX, pInfoY, uilook(uiText), vpage END IF draw_overlays() END SUB 'Additional logic around update() SUB ModularMenu.update_wrapper() clear_menu() update() init_menu_state state, menu(), menuopts 'Updates .size, .last, .pt., .hover, .top 'Updating shaded() is optional REDIM PRESERVE shaded(UBOUND(menu)) IF use_selectable THEN 'Move state.pt to a selectable menu item (yuck!) '(This is also normally done by init_menu_state, but we can't call that) WHILE selectable(state.pt) = NO IF state.pt <= state.first THEN EXIT WHILE state.pt -= 1 WEND correct_menu_state_top state END IF mouse_update_hover state 'Not actually needed; hover won't be incorrect unless the menu grew 'correct_menu_state state 'Also calls mouse_update_hover END SUB SUB ModularMenu.run() running = YES IF floating THEN holdscreen = allocatepage copypage vpage, holdscreen menuopts.wide = 80 menuopts.calc_size = YES END IF state.autosize = YES state.autosize_ignore_lines = 1 'For the tooltip 'If floating, the title gets cut off when the menu is small IF floating = NO ANDALSO LEN(title) THEN state.autosize_ignore_lines += 2 update_wrapper() IF floating THEN draw() 'To calculate state.rect DO setwait 55 setkeys YES IF use_selectable THEN usemenu_ret = usemenu(state, selectable()) ELSE usemenu_ret = usemenu(state) END IF can_use_strgrabber = (LEN(selectst.query) = 0) using_strgrabber = NO 'Call each_tick before checking cancel and help keys, so it can override them if it wants IF each_tick() THEN EXIT DO IF keyval(ccCancel) > 1 ANDALSO try_exit() THEN EXIT DO IF floating ANDALSO readmouse.release AND (mouseLeft OR mouseRight) THEN IF NOT rect_collide_point(state.rect, readmouse.pos) THEN IF try_exit() THEN EXIT DO END IF END IF IF LEN(helpkey) ANDALSO keyval(scF1) > 1 ANDALSO (keyval(scCtrl) OR keyval(scShift)) = 0 THEN show_help helpkey END IF IF state.need_update THEN state.need_update = NO update_wrapper() END IF IF using_strgrabber = NO ANDALSO select_by_typing(selectst, NO) THEN select_on_word_boundary menu(), selectst, state END IF IF floating THEN copypage holdscreen, vpage ELSE clearpage vpage END IF draw() setvispage vpage dowait LOOP setkeys IF holdscreen THEN copypage holdscreen, vpage 'Just in case something else also does holdscreen freepage holdscreen END IF clear_menu() running = NO END SUB '========================================================================================== ' MenuStack '========================================================================================== #IFDEF IS_CUSTOM FUNCTION MenuStack.cur_menu() as MenuDef ptr DIM idx as integer = UBOUND(menus) IF idx < 0 THEN RETURN NULL RETURN @menus(idx) END FUNCTION FUNCTION MenuStack.cur_item() as MenuDefItem ptr DIM idx as integer = UBOUND(menus) IF idx < 0 THEN RETURN NULL DIM byref menu as MenuDef = menus(idx) DIM byref state as MenuState = states(idx) IF state.pt < 0 ORELSE state.pt >= menu.numitems THEN RETURN NULL RETURN menu.items[state.pt] END FUNCTION FUNCTION MenuStack.add_item(text as string, t as integer = 0, sub_t as integer = 0, dataptr as any ptr = 0) byref as MenuDefItem DIM byref menu as MenuDef = menus(UBOUND(menus)) DIM midx as integer = append_menu_item(menu, text, t, sub_t, dataptr) RETURN *menu.items[midx] END FUNCTION ' Opens a new menu at the default position: the mouse position for the first menu, ' or right of the current active menu item if there is a menu open. ' Call open_menu(), then add_item() to add to the new menu, then finish with finish_open(). SUB MenuStack.open_menu() DIM idx as integer = UBOUND(menus) IF idx >= 0 THEN DIM byref menu as MenuDef = menus(idx) DIM byref state as MenuState = states(idx) open_menu_at menudef_item_rect(menu, state, menu.items[state.pt]->text, state.pt) open_at_mouse = NO ELSE open_menu_at(XYWH(0,0,0,0)) open_at_mouse = YES END IF END SUB ' Open a new menu to the right of some existing menu item/widget at itemrect SUB MenuStack.open_menu_at(itemrect as RectType) DIM idx as integer = UBOUND(menus) + 1 REDIM PRESERVE menus(idx) REDIM PRESERVE states(idx) DIM byref menu as MenuDef = menus(idx) menu.game_menu = NO menu.offset = itemrect.xy + XY(itemrect.w + 3, -1) menu.highlight_selection = YES 'Add a little more border in case of a scrollbar 'menu.bordersize += 4 'Seems to do nothing... menu.no_box = YES 'menu.suppress_borders = YES IF idx > 0 THEN states(idx - 1).active = NO END IF END SUB ' Must be called after open_menu[_at] and add_item SUB MenuStack.finish_open() DIM idx as integer = UBOUND(menus) DIM byref menu as MenuDef = menus(idx) DIM byref state as MenuState = states(idx) init_multichoice_menu menu, state, menu.offset, , YES 'popup_style=YES update_menu_captions menu calc_menu_rect state, menu, vpage 'Get the menu size IF open_at_mouse THEN ' By default the menu's at the mouse pointer and clamped to screen, move it so doesn't overlap the mouse DIM menusize as XYPair = menu.rect.wh + 4 'menu.rect includes the border, state.rect doesn't IF LEN(menu.name) THEN DIM titlesize as XYPair = textsize(menu.name) + XY(4, 4) menusize.w = large(menusize.w, titlesize.w) menusize.h += titlesize.h END IF menu.offset = pick_tooltip_pos(menusize) calc_menu_rect state, menu, vpage 'Get the menu position END IF END SUB ' Close the topmost menu SUB MenuStack.close_menu() IF UBOUND(menus) < 0 THEN EXIT SUB DIM idx as integer = UBOUND(menus) - 1 ClearMenuData menus(idx + 1) IF idx = -1 THEN ERASE menus ERASE states ELSE REDIM PRESERVE menus(idx) REDIM PRESERVE states(idx) states(idx).active = YES END IF END SUB FUNCTION MenuStack.is_open() as bool RETURN UBOUND(menus) >= 0 END FUNCTION FUNCTION MenuStack.is_active() as bool IF UBOUND(menus) < 0 THEN RETURN NO DIM byref state as MenuState = states(UBOUND(states)) RETURN state.active END FUNCTION ' Does not check actually active FUNCTION MenuStack.activate() as bool IF UBOUND(menus) < 0 THEN RETURN NO DIM byref state as MenuState = states(UBOUND(states)) RETURN enter_space_click(state) ORELSE keyval(ccRight) > 1 END FUNCTION ' You may have to override this to handle 'activate' properly FUNCTION MenuStack.controls() as bool IF UBOUND(menus) < 0 THEN RETURN NO DIM byref state as MenuState = states(UBOUND(states)) IF state.active = NO THEN RETURN NO IF keyval(scF1) > 1 ANDALSO LEN(helpkey) > 0 THEN show_help helpkey END IF IF activate THEN RETURN YES ELSE default_menu_controls state IF keyval(ccLeft) > 1 THEN state.active = NO IF state.active = NO THEN 'Set when quitting (or activating) close_menu() END IF END IF END FUNCTION SUB MenuStack.draw(page as integer) FOR midx as integer = 0 TO UBOUND(menus) DIM byref menu as MenuDef = menus(midx) DIM byref state as MenuState = states(midx) IF midx < UBOUND(menus) THEN ' Tint background menu items darker WITH master(uilook(uiMenuItem)) menu.textcolor = findrgb(.r - 32, .g - 32, .b - 32) END WITH ELSE menu.textcolor = 0 'Reset END IF DIM res as XYPair = get_resolution ' Display the menu name as a title in a box above ' Maybe this should be a feature of draw_menu, but probably better that people use separate slices for it IF LEN(menu.name) THEN DIM titlesize as XYPair = textsize(menu.name) + XY(4, 4) menu.rect.x = small(menu.rect.x, res.w - titlesize.w) menu.rect.y = large(menu.rect.y, titlesize.h) DIM titlerect as RectType = menu.rect titlerect.wh = titlesize titlerect.y -= titlerect.h drawbox vpages(page), titlerect.x + 2, titlerect.y + 2, titlerect.w, titlerect.h, findrgb(0, 0, 0), 2 rectangle vpages(page), titlerect, findrgb(0, 80, 140) DIM uicol as integer = IIF(midx = UBOUND(menus), uiText, uiMenuItem) edgeprint menu.name, titlerect.x + 2, titlerect.y + 2, uilook(uicol), page END IF ' Draw a drop shadow drawbox vpages(page), menu.rect.x + 3, menu.rect.y + 3, menu.rect.w, menu.rect.h, findrgb(0, 0, 0), 3 rectangle vpages(page), menu.rect, findrgb(40, 40, 70) ' Draw outline drawbox vpages(page), menu.rect.x, menu.rect.y, menu.rect.w, menu.rect.h, findrgb(80, 80, 80), 1 'menu.max_chars = get_resolution().w \ 8 - 2 draw_menu menu, state, page IF LEN(helpkey) THEN DIM x as integer ' Don't overlap the menu IF menu.rect.x + menu.rect.w > res.w - 60 ANDALSO menu.rect.y + menu.rect.h > res.h - 15 THEN x = pInfoX ELSE x = pInfoRight END IF edgeprint "F1 Help", x, pInfoY, uilook(uiMenuItem), vpage END IF ' wrapprintbg *extra_message, pLeft, pBottom, uilook(uiMenuItem), vpage, , , , fontBuiltinEdged NEXT END SUB #ENDIF 'IS_CUSTOM