'OHRRPGCE - EditorKit framework for creating editors
'(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.

' ==== EditorKit classes ====
' To create an editor with EditorKit, create a UDT Extending EditorKit, and
' implement sub define_items(). This will be called repeatedly for:
' processing: it's called every tick to handle editing of data fields
' activating: clicking or space/enter activating a menu item (actually the same as
'   the processing phase)
' refreshing: generate an array of items for display and navigation; called
'   whenever state.need_update is true (which define_items sets during processing)
'
' Then call .run(). But you probably want to load data or do other initialisation
' first, such as setting .helpkey.
'
' ==== Submenus ====
' Define submenus by branching on the value of `submenu` inside .define_items()
' and enter one by calling .enter_submenu() (which nests) or .switch_submenu().
' For a submenu-specific help page set helpkey in define_items (not set_helpkey()!)
'
' ==== Saving, loading, and multiple records ====
' If you overload the load() and save() methods they will be called when
' run() begins and right before it ends.
' To switch between multiple records you need to call setup_record_switching before
' .run() to define what the variable is that holds the current record number.
' load() and save() are assumed to access this variable (hence they take no args),
' and they are both called when switching records.
' Then call def_record_switcher in define_items() to add a <-Foo #-> line.
'
' ==== Adding menu items ====
' "Previous Menu" (customisable with prev_menu_text) is added automatically.
'
' To add a menu item, call from define_items():
' -spacer: a blank line
' -section or subsection: add a section header, which is unselectable. In future,
'  sections will be collapsible
' -defitem, or other def* function (which are convenience wrappers around defitem)
'
' Each defitem menu item is split into a "title" and a "caption" (either of
' which may be blank).  The title is the field description and should usually
' end in ':'; the caption presents the value. The title is set only by defitem/def*.
' The caption is set automatically to the value but you can replace it with
' set_caption or tell how to generate it with caption* functions.
'
' ==== State variables ====
' A menu item definition starting with defitem ends at the next defitem/spacer/etc
' call. defitem by itself just creates a menu item that does nothing.
'
' Inside the definition you can query some bool members:
' -selected: this menu item is the selected item
' -refresh: refreshing the menu
' -process: selected and should do per-tick logic, such as calling intgrabber
' -process_text: should be checked instead of process to decide whether to read text
'          input. (True even if Alt is held, but not when selecting-by-typing)
'          (Be sure to set using_strgrabber if you do.)
' -activate: selected and should be activated (if possible), e.g. enter a submenu.
' -delete_action: the user tried to delete this (Delete or possibly Backspace)
' -hover: mouse over this item
' -left_click: beginning of a left click/drag on this item. Use activate instead,
'          if you can which checks for button release.
' -right_click: beginning of a right click/drag.
' And a couple you can read/write:
' -edited: an edit_* call changed the item's value. You should set this manually if
'          you modify `value` manually.
' -state.needs_update: can also be set to indicate the menu needs refreshing
'
' So if you want to enter a submenu:
'     defitem "Edit details..."
'     if activate then edit_widget_details
' Or as a shortcut:
'     if defitem_act("Edit details...") then edit_widget_details
' Or if the submenu is defined within the same class:
'     if defitem_act("Edit details...") then enter_submenu "details"
'
' ==== Data ====
' Items can display and (optionally) edit a field of data, which could be an
' integer/string/etc passed byref, a RELOAD Node, ohrrpgce_config.ini setting,
' or general.reld setting. It works like so:
'
' -The datum is read into `value` (ints and bools), `valuestr` or `valuefloat`
'  by calling val_*, as_*, edit_*, edit_as_* or def*, and its source (eg. a 
'  Node) is recorded.
' -The value can be shifted with `offset_int`, or a bool inverted with
'  `invert_bool` or by prefixing the title with '!' (just like editbitset). Must
'  happen before editing or setting the caption.
' -edit_* methods will, `if process`, call intgrabber/etc to modify `value`/etc
'  and set `edited`. They also immediately call write_value for safety.
' -If `edited` is true, the value is written back; if you have custom editing
'  code (e.g. a *grabber call) that modifies value/etc you should set `edited`.
'  (This happens even during refresh, so it's OK to modify the value then.)
'
' You don't need to set the value with val_*/etc if you write custom editing code
' enclosed in `if process` and use set_caption for the caption.
'
' The families of available methods:
'
' -val_* to tell which value to edit. Examples:
'     val_int gen(genItemStackSize)  'Passing a value byref to record a ptr to it
'     val_node_int DocumentRoot(doc)  'A Node to edit (doesn't need to be byref)
'     'Uses NodeByPath to get a child, with default value "Weapon" if missing
'     val_node_str menunode, "/weapon/caption", "Weapon"
'     val_bitset bits(), 0, 35  'Starting from word 0, bit 35
'
' -as_* to tell what the value means, e.g. a tag check, enemy ID, or script
'  trigger - you won't use this for raw data. This just changes the default
'  caption (normally you use edit_as_* instead). Pass value/etc as the first
'  arg, e.g.
'     val_int rec(42)
'     as_enemy value
'  Which can also be written
'     as_enemy val_int(rec(42))
'  As a shortcut for byref data (val_int/bool/str/float) you can skip the val_*:
'     as_enemy rec(42)
'
' -edit_* to tell how to edit a value (if processing), e.g.:
'     val_node_int boxstyle_node
'     edit_int value, 0, 14   'Range 0 to 14
'  ...but as a shortcut you can skip the val_* (there are edit_X functions for
'  most val_X):
'     edit_node_int boxstyle_node, 0, 14
'  If you use an explicit val_* then he first arg to edit_* will be value/etc.
'
' -def*: As a further shortcut for simple values, you can use a def* method
'  which combines defitem and edit_*:
'     defitem "Default maximum item stack size:"
'     edit_int gen(genItemStackSize), 1, 99
'  can become:
'     defint "Default maximum item stack size:", gen(genItemStackSize), 1, 99
'  which is complete!
'
'  You can NOT write something like "defint "...", val_node_int(...), 0, 10"
'  because the menu item doesn't start until defitem is called.
'
' -edit_as_* for game data like tags or enemies, extends as_* with editing,
'  including bounds, entering browsers/submenus, etc. There's an edit_as_X for
'  every as_X. E.g.
'     edit_as_enemy rec(42)
'
' ==== "None" options and offset values ====
' Many edit_as_* methods take an Or_None flag to indicate -1 means None:
'     edit_as_enemy rec(42), Or_None
'
'  If you want 0 on-disk to be None and N > 0 to be record N-1 then use
'  offset_int to shift `value` from the on-disk value:
'     offset_int -1   'Can be called either before or after val_*
'     edit_as_enemy rec(42), Or_None
'  Alternatively:
'     edit_as_enemy offset_int(-1, rec(42)), Or_None
'  You can write it this equivalent way:
'     val_int rec(42)
'     value -= 1
'     edit_as_enemy value, Or_None
'     value += 1
'
' ==== Captions ====
' The caption defaults to the item's value if the title ends in ':'.  It can be
' set it with set_caption, or a caption* function such as `captions` for enum
' strings.  caption* methods (other than set_caption) must be called after
' value/valuestr/valuefloat is set!
'
' Example:
'     defint "Display '" & CHR(1) & "1' in inventory:", gen(genInventSlotx1Display), 0, 2
'     captions_list("always", "never", "only if stackable")
'
' ==== More examples ====
'
' You can call methods conditionally, as long as the same defitems are called during the
' processing and refreshing phases so the the menu item indices match.
' For example, in the formation editor, activation handling needs to overridden:
'     for slot as integer = 0 to ubound(form.slots)
'       defitem "Enemy:"
'       offset_int -1   '0 is None, 1+ is enemy ID+1
'       as_enemy form.slots(slot).id, Or_None   'Sets caption
'       if activate then
'         edited or= reposition_or_change_enemy_submenu(value)
'       else
'         edit_as_enemy value, Or_None   'Calling both as_enemy and edit_as_enemy is harmless
'       end if
'       if value = -1 then set_caption "Empty"  'Override default "None" caption
'     next

#include "config.bi"
#include "common.bi"
#include "reloadext.bi"
#include "editorkit.bi"
#include "loading.bi"
#include "custom.bi"
#include "customsubs.bi"
#include "sliceedit.bi"   'extra_data_editor
#include "thingbrowser.bi"

using Reload.Ext

DEFINE_VECTOR_OF_TYPE(SubmenuState, SubmenuState)


'===============================================================================
'                               Overridable methods

' define_items is abstract, must be overridden. Others are optional.

' Called when entering the menu and after switching records (the record id passed to
' setup_record_switching is modified before calling this).
' NOTE: you should set state.need_update = YES, as it possibly won't be automatically.
sub Editorkit.load()
end sub

' Called when exiting the menu and before switching records (the record id passed to
' setup_record_switching is modified after calling this).
sub EditorKit.save()
end sub

' Used by the Alt quick record switcher, normally the name of the record, or some other
' suitable preview text. Shouldn't include the id, it'll be appended.
' Defaults to name of the record type, e.g. "Textbox".
function EditorKit.get_record_name(id as integer) as string
	return record_type_name
end function

'===============================================================================
'                         ModularMenu hooks (internal)

sub EditorKit.update()
	' ModularMenu calls update() before any other hooks
	if initialised = NO then
		load()
		' On the first call store helpkey, as it may get clobbered
		if len(default_helpkey) = 0 then default_helpkey = base.helpkey
		initialised = YES
	end if

	want_exit = NO
	want_activate = NO

	run_phase(Phases.refreshing)
end sub

function EditorKit.each_tick() as bool
	base.helpkey = default_helpkey
	base.tooltip = ""
	want_exit = keyval(ccCancel) > 1
	want_submenu = "NO"
	want_activate = enter_space_click(state)
	record_id_grabber_called = NO

	run_phase(Phases.processing)

	'if enter_space_click(state) then
	'	activated_item = state.pt
	'	state.need_update or= run_phase(Phases.activating)
	'end if
	'if state.need_update = NO then
	'	run_phase(Phases.processing)
	'end if

	' Alt to change record.
	if record_id_ptr then
		' On string fields, Alt is for entering special characters.
		' This also means we have to do this after run_phase
		if keyval(scAlt) > 0 andalso not using_strgrabber then
			' Disable select-by-typing, although we don't use text input
			using_strgrabber = YES

			record_id_grabber
		end if
	end if

	if want_exit then
		if v_len(submenu_stack) then  'Exit submenu
			want_submenu = v_pop(submenu_stack)
		elseif try_exit() then  'Exit root menu
			save()
			return YES
		end if
		' Don't let ModularMenu handle it
		if keyval(ccCancel) > 1 then setkeys
	end if
	if want_submenu <> "NO" then
		apply_enter_submenu want_submenu
	end if

	'tooltip = v_str(submenu_stack) & " '" & submenu & "'"
	return NO
end function

sub EditorKit.draw_overlays()
	if record_id_ptr andalso keyval(scAlt) > 0 then
		var id = *record_id_ptr
		textcolor uilook(uiText), uilook(uiHighlight)
		printstr get_record_name(id) & " " & id, pRight, 0, vpage
	end if
end sub

'===============================================================================
'                                   Internal

constructor EditorKit()
	v_new submenu_stack
	saved_submenus.construct(8, type_table(SubmenuState), YES)
	saved_submenus.value_copy = NULL   'Delete but don't copy
end constructor

destructor EditorKit()
	v_free submenu_stack
end destructor

' Wrapper around define_items()
sub EditorKit.run_phase(which_phase as Phases)
	phase = which_phase
	cur_item_index = 0
	started_item = NO
	edited = NO

	refresh = (phase = Phases.refreshing)
	process = NO
	activate = NO

	if refresh then clear_menu

	defitem prev_menu_text
	if activate then want_exit = YES

	define_items()
	' End the final item
	finish_defitem
end sub

' Called after an item definition is finished
sub EditorKit.finish_defitem()
	if started_item = NO then exit sub

	if activate then
		' If you enter some editor and then exit it by hitting ESC/etc then we
		' need to ignore that cancel key or this menu will exit.
		' (Ideally would call setkeys regardless of how you exit the menu,
		' but it's not possible to tell that we entered one, and if you exit
		' it any other way there doesn't seem to be a possibility of
		' misinterpreting input.)
		if keyval(ccCancel) > 1 then setkeys
	end if

	if edited then
		state.need_update = YES
		write_value
		edited = NO
	end if

	if selected then
		if cur_item.helpkey <> "" and tooltip = "" then tooltip = "F1 for details"
	end if

	cur_item_index += 1

	if refresh then
		with cur_item
			dim as string text, title = .title, caption = .caption
			' If there's no caption, use the current data value as a default
			if len(caption) = 0 andalso ends_with(.title, ":") then
				select case .dtype
					case dtypeBool:   caption = iif(value, "YES", "NO")
					case dtypeInt:    caption = str(value)
					case dtypeFloat:  caption = str(valuefloat)
					case dtypeStr:    caption = valuestr
				end select
			end if

			if len(title) > 0 then
				title &= " "
			end if

			if .color then text = fgtag(ColorIndex(.color))
			text &= title & caption

			base.add_item .id, 0, text, (.unselectable = NO), NO, .disabled
		end with
	end if

	started_item = NO
end sub

' Get or create a SubmenuState
function EditorKit.get_submenu_state(name as string) as SubmenuState ptr
	dim ret as SubmenuState ptr
	ret = saved_submenus.get(name)
	if ret = null then
		ret = new SubmenuState
		saved_submenus.add(name, ret)
	end if
	return ret
end function

' Delayed switch/enter_submenu logic
sub EditorKit.apply_enter_submenu(name as string = "")
	state.need_update = YES

	' Save old state
	with *get_submenu_state(submenu)
		.pt = state.pt
		.top = state.top
	end with

	submenu = name

	' Restore previous state (blank if never visted)
	with *get_submenu_state(name)
		state.pt = .pt
		state.top = .top
	end with
end sub

' Helper for writeNodePath*
private function create_or_delete_default_node(cur_item as EditorKitItem, is_default as bool) as Node ptr
	with cur_item
		dim valnode as Node ptr
		if .delete_default andalso is_default then
			valnode = NodeByPath(.node, .path)
			if valnode then FreeNode valnode
		else
			return NodeByPath(.node, .path, YES)  'Create it
		end if
	end with
end function

' Write a modified value/valuestr/valuefloat back to where it was read from -- called when edited=YES
sub EditorKit.write_value()
	with cur_item
		if .writer = writerNone andalso .dtype = dtypeNone then
			' Happens if you use just defitem, do everything yourself,
			' but set the 'edited' flag, or if you call dont_write or delete_node.
			exit sub
		end if

		' Get the actual output integer value to write (no changes needed to string/float)
		dim outvalue as integer = value
		if .offset then
			assert(.dtype = dtypeInt)
			outvalue -= .offset
		end if
		if .inverted_bool then
			assert(.dtype = dtypeBool)
			outvalue xor= YES
		end if

		select case as const .writer
			case writerByte
				*.byte_ptr = outvalue
			case writerBoolean
				' FB booleans take value 0/-1 but are stored in
				' memory as a 0/1 byte (to match a C/C++ bool)
				*.byte_ptr = iif(outvalue, 1, 0)
			case writerBit
				if outvalue then
					*.int_ptr or= .whichbit
				else
					*.int_ptr and= not .whichbit
				end if
			case writerInt  'Includes bool
				*.int_ptr = outvalue
			case writerStr
				*.str_ptr = valuestr
			case writerDouble
				*.double_ptr = valuefloat
			case writerNodeInt
				SetContent(.node, outvalue)
			case writerNodeBool
				SetContent(.node, iif(outvalue, 1, 0))
			case writerNodeStr
				SetContent(.node, valuestr)
			case writerNodeFloat
				SetContent(.node, valuefloat)
			case writerNodePathInt, writerNodePathBool
				var valnode = create_or_delete_default_node(cur_item, outvalue = .writer_default_int)
				if .writer = writerNodePathBool then
					outvalue = iif(outvalue, 1, 0)
				end if
				if valnode then SetContent(valnode, outvalue)
			case writerNodePathStr
				var valnode = create_or_delete_default_node(cur_item, valuestr = .writer_default_str)
				if valnode then SetContent(valnode, valuestr)
			case writerNodePathFloat
				var valnode = create_or_delete_default_node(cur_item, valuefloat = .writer_default_float)
				if valnode then SetContent(valnode, valuefloat)
			case writerNodePathExists
				' If value is true, create it
				var valnode = NodeByPath(.node, .path, outvalue <> NO)
				if outvalue = NO andalso valnode then
					' If value is false, delete it
					FreeNode valnode
				end if
			case writerConfigBool
				write_config .path, yesorno(outvalue)

			case else  ' Including writerNone
				' We should have set both .dtype and .writer in val_*
				showbug "EditorKit: bad/missing writer"
		end select
	end with
end sub

'===============================================================================
'                             Editor setup routines

' max_record will only be modified if max_record_max is provided.
' max_record_max_ is the maximum number of records that can be added. Adding new records is
'   enabled by providing this value.
sub EditorKit.setup_record_switching(byref record_id as integer, min_record as integer = 0, byref max_record as integer, max_record_offset_ as integer = 0, record_type_name_ as string = "Record", max_record_max_ as integer = 0)
	record_id_ptr = @record_id
	min_record_id = min_record
	max_record_id_ptr = @max_record
	max_record_offset = max_record_offset_
	max_record_max = max_record_max_
	record_type_name = record_type_name_
end sub

/'
' Call this to allow deleting records from the end ("cropafter")
sub EditorKit.setup_cropafter(...)
	cropafter_lump = lump
	'Delete when selected, or Shift-Backspace everywhere
end sub
'/

'===============================================================================
'                          Other non-menu-item methods

' User wants to delete this item (e.g. Delete key)
' (In future, may cause a clickable delete icon to display)
function EditorKit.delete_action() as bool
	if process then
		' Most editing routines already listen for Backspace
		if can_use_strgrabber andalso cur_item.dtype = dtypeNone then
			if keyval(scBackspace) > 1 then return YES
		end if
		return keyval(scDelete) > 1
	end if
end function

' Call during 'process' or 'activate' to do a conditional exit from the menu.
' Exits the submenu, if any, otherwise, the try_exit() virtual method will be called,
' which can override the attempt to completely exit the menu.
sub EditorKit.exit_menu()
	want_exit = YES
end sub

' Enter a submenu, forming a stack of submenus, or if it's already on the stack,
' return to it and don't push the current menu.
' Regardless of whether a submenu is on the stack, its menu cursor is saved.
' The main visible effect of this is setting 'submenu'.
sub EditorKit.enter_submenu(name as string = "")
	if name = submenu then
		' This would mess up the stack
		'debug "noop enter_submenu(" & name & "), stack=" & v_str(submenu_stack)
		exit sub
	end if

	' If already stacked (open) go back to that stack frame, otherwise push onto stack
	dim idx as integer = v_find(submenu_stack, name)
	if idx > -1 then
		v_delete_slice(submenu_stack, idx, v_len(submenu_stack))
	else
		v_append(submenu_stack, submenu)
	end if

	' Delay changing state.pt, because it would mess up 'selected', 'activate', etc.
	want_submenu = name
end sub

' Enter a submenu, which becomes the root menu (exiting from it exits completely).
' Good for a tabbed menu.
sub EditorKit.switch_submenu(name as string = "")
	v_resize(submenu_stack, 0)
	want_submenu = name
end sub

sub EditorKit.switch_record(newid as integer)
	BUG_IF(record_id_ptr = 0, "Missing setup_record_switching")
	dim byref id as integer = *record_id_ptr
	save()
	id = newid
	load()
	state.need_update = YES  'load() is also meant to do this
end sub

/'
sub EditorKit.crop_after_record(...)
end sub
'/

' Allow typing in/editing the record ID number. Switches record and can also add new records if
' max_record_max was provided.
' Returns true if record changed.
function EditorKit.record_id_grabber() as bool
	' There are multiple ways to call this, only check once per tick
	if record_id_grabber_called then return NO
	record_id_grabber_called = YES

	BUG_IF(record_id_ptr = 0 orelse max_record_id_ptr = 0, "Missing setup_record_switching", NO)
	dim byref id as integer = *record_id_ptr
	dim byref maxvar as integer = *max_record_id_ptr
	dim maxid as integer = maxvar + max_record_offset
	dim newid as integer = id
	if max_record_max > 0 then
		intgrabber_with_addset(newid, 0, maxid, max_record_max, record_type_name)
		if newid > maxid then
			maxvar = newid - max_record_offset
		end if
	else
		intgrabber(newid, 0, maxid)
	end if
	if newid <> id then switch_record newid
	return newid <> id
end function

'===============================================================================
'                            Non-data menu item types

' Add a blank line
sub EditorKit.spacer()
	finish_defitem
	if refresh then base.add_spacer
	cur_item_index += 1
end sub

' Add an unselectable, highlighted & offset section header line, preceded by spacer
sub EditorKit.section(title as zstring ptr)
	finish_defitem
	' Doesn't count as an item
	if refresh then base.header " " & *title
	cur_item_index += 2
end sub

' Add an unselectable highlighted subsection header. No spacer in front
sub EditorKit.subsection(title as zstring ptr)
	finish_defitem
	' Doesn't count as an item
	if refresh then base.add_item , , *title, NO, YES
	cur_item_index += 1
end sub

sub EditorKit.def_record_switcher()
	BUG_IF(record_id_ptr = 0, "Missing setup_record_switching")
	dim id as integer = *record_id_ptr

	defitem chr(27) & record_type_name & " " & id & chr(26)
	if process then
		if record_id_grabber() then
			edited = YES
		end if
	end if

	/'
	'Not using delete_action() because don't allow Backspace
	if process andalso keyval(scDelete) > 1 then
		crop_after_record
	end if
	'/
end sub

'===============================================================================
'                            def* menu item functions

sub EditorKit.defitem(title as zstring ptr)
	finish_defitem
	started_item = YES

	' Set all the per-item state variables
	selected = (state.pt = cur_item_index)
	hover = (state.hover = cur_item_index)

	'? "defitem " & cur_item_index & " " & *title
	refresh = (phase = Phases.refreshing)
	var maybe_process = selected andalso (phase = Phases.processing)
	' Hold Alt to edit the record number, except on a text field, where Alt
	' can be used to input text.
	process = maybe_process andalso (keyval(scAlt) = 0)
	process_text = maybe_process andalso can_use_strgrabber

	'activate = selected andalso (phase = Phases.activating)
	activate = process andalso want_activate
	left_click = process andalso (readmouse.clicks and mouseLeft)
	right_click = process andalso (readmouse.clicks and mouseRight)

	if combo_key1 then
		if combo_key2 then
			if keyval(combo_key1) andalso keyval(combo_key2) > 1 then activate = YES
		else
			if (keyval(scCtrl) or keyval(scShift) or keyval(scAlt)) = 0 andalso keyval(combo_key1) > 1 then activate = YES
		end if
		' Can't wipe these in finish_defitem so have to do it here
		combo_key1 = scNone
		combo_key2 = scNone
	end if

	' Start new item
	'if refresh then
		cur_item.destructor()
		cur_item.constructor()
		cur_item.id = cur_item_index
		if title andalso title[0] = asc("!") then
			cur_item.inverted_bool = YES
			' No value is set yet, don't need to invert it
			cur_item.title = *(title + 1)
		else
			cur_item.title = *title
		end if
	'end if

	'return cur_item_index
end sub

function EditorKit.defitem_act(title as zstring ptr) as bool
	defitem title
        if activate then state.need_update = YES
	return activate
end function

' Add an unselectable line. You can still use set_caption or even val_*/as_* to set a default caption
sub EditorKit.defunselectable(title as zstring ptr, color as integer = -eduiNote-1)
	defitem title
	cur_item.unselectable = YES
	cur_item.color = color
end sub

sub EditorKit.defdisabled(title as zstring ptr)
	defitem title
	set_disabled
end sub

sub EditorKit.defint(title as zstring ptr, byref datum as integer, min as integer = 0, max as integer)
	defitem title
	edit_int datum, min, max
end sub

sub EditorKit.defbool(title as zstring ptr, byref datum as bool)
	defitem title
	edit_bool datum
end sub

sub EditorKit.defbool(title as zstring ptr, byref datum as boolean)
	defitem title
	edit_bool datum
end sub

sub EditorKit.defbitset(title as zstring ptr, bitwords() as integer, wordnum as integer = 0, bitnum as integer)
	defitem title
	edit_bitset bitwords(), wordnum, bitnum
end sub

sub EditorKit.defstr(title as zstring ptr, byref datum as string, maxlen as integer = 0)
	defitem title
	edit_str datum, maxlen
end sub

'===============================================================================
'                                    Captions

' Set the display value for the current item, overriding default conversion of the
' value to a string.
sub EditorKit.set_caption(caption as zstring ptr)
	if refresh then
		cur_item.caption = *caption
	end if
end sub

' Internal
sub EditorKit.wrap_caption(caption as string)
	if value = cur_item.default_value then
		set_caption "Default (" & caption & ")"
	else
		set_caption caption
	end if
end sub

sub EditorKit.caption_default_or_int(default_value as integer = 0, default_caption as zstring ptr = @"Default")
	if refresh then
		'cur_item.default_value = default_value  'Not used for anything
		'cur_item.default_eff_value = MIN_INT    'Unknown
		cur_item.caption = iif(value = default_value, *default_caption, str(value))
	end if
end sub

sub EditorKit.caption_default_or_str(default_caption as zstring ptr = @"[default]")
	if refresh then
		cur_item.caption = iif(len(valuestr), valuestr, *default_caption)
	end if
end sub

sub EditorKit.captions_bool(nocapt as zstring ptr, yescapt as zstring ptr)
	if refresh then
		cur_item.caption = *iif(value, yescapt, nocapt)
	end if
end sub

' Shows "Invalid <thing> ##" if the value is out of bounds
sub EditorKit.captions(captions_array() as string, invalid_thing as zstring ptr = @"value")
	if refresh then
		cur_item.caption = safe_caption(captions_array(), value, *invalid_thing)
	end if
end sub

' Due to FB bug sf#666 (fixed in 1.09) it's not possible to define an overload of
' captions() which takes a zstring ptr array.
sub EditorKit.captionsz(captions_array() as zstring ptr, invalid_thing as zstring ptr = @"value")
	if refresh then
		cur_item.caption = safe_captionz(captions_array(), value, *invalid_thing)
	end if
end sub

' Shows value as an int if it's out of bounds
sub EditorKit.captions_or_int(captions_array() as string)
	if refresh then
		cur_item.caption = caption_or_int(captions_array(), value)
	end if
end sub

'===============================================================================
'                           Other menu item attributes

' Makes this menu item unselectable, but does not change its colour
sub EditorKit.set_unselectable()
	cur_item.unselectable = YES
end sub

' Disables editing and other processing, and changes to the disabled color.
' That means it has to precede edit_* methods, e.g. replace "defstr X, Y" with
'  defitem X : set_disabled : edit_str Y
sub EditorKit.set_disabled()
	if edited then
		showbug "editorkit: set_disabled must precede edit_*"
	end if
	cur_item.disabled = YES
	cur_item.color = NO  'Override previous set_color
	activate = NO
	left_click = NO
	right_click = NO
	' TODO: really shouldn't change process, instead add something like
	' `editing`/`editing_text` that most things check instead.
	process = NO
	process_text = NO
end sub

' The id isn't used for anything currently
sub EditorKit.set_id(id as integer)
	cur_item.id = id
end sub

' The color is a master palette index or -uicol - 1 for a UI constant.
' Overrides color from a previous set_disabled().
sub EditorKit.set_color(color as integer)
	cur_item.color = color
end sub

' Set which help page is opened by F1 while the current menu item is selected
' Also sets a default tooltip "F1 for details" while selected
sub EditorKit.set_helpkey(key as zstring ptr)
	if process then
		base.helpkey = *key
		cur_item.helpkey = *key
	end if
end sub

' Set text that appears at the bottom of the screen while this item is selected
' (TODO: if the mouse moves, depend on mouse hover instead)
sub EditorKit.set_tooltip(text as zstring ptr)
	if selected then base.tooltip = *text
end sub

' Sets a combo key to activate the next defitem. If used with a single key, check keyval(key1) > 1.
' If used with two keys, the first is a modifier: keyval(key1) > 0 and keyval(key2) > 1.
' Note: unlike all other attributes this must precede the menu item! So that 'activate' can be set.
sub EditorKit.keycombo(key1 as KBScancode, key2 as KBScancode = scNone)
	combo_key1 = key1
	combo_key2 = key2
end sub

' Set the effective value for purposes of previewing and captioning when `value`
' is equal to `default_value`, for example:
'  Line sound: default (6 Blip sfx)
' (This default is distinct from the default value of a missing Node.)
' NOTE: unlike other caption functions, must be called before edit_as_*/as_*!
sub EditorKit.default_effective_value(default_value as integer, effective_value as integer)
	' False alarm if set_caption called otherwise, but disallowing that doesn't matter
	BUG_IF(len(cur_item.caption), "must be called before as_*/edit_as_*")
	cur_item.default_value = default_value
	cur_item.default_eff_value = effective_value
end sub

' Get the effective value (see default_effective_value)
function EditorKit.eff_value() as integer
	if value = cur_item.default_value then
		return cur_item.default_eff_value
	else
		return value
	end if
end function

' Call this after edit_str (or defstr or any other string type) to allow multiline editing.
' Call set_helpkey before this to make it use that helpkey inside.
function EditorKit.multiline_editable() as bool
	' Note that edit_str ensures Space won't activate
	if activate then
		valuestr = multiline_string_editor(valuestr, cur_item.helpkey, NO)  'prompt_to_save=NO
		edited = YES
		write_value
		return YES
	end if
end function

' Don't attempt to write back the value, e.g. because it's been written manually
' and value/valuestr/etc is stale.
sub EditorKit.dont_write()
	cur_item.dtype = dtypeNone
	cur_item.writer = writerNone
end sub


'===============================================================================
'                          val_* value definition functions

' Functions to tell which piece of data is associated with the menu item.
' These set value/valuestr/valuefloat, and record its dtype and the writer for it.
' These behave very similiarly to as_* functions, except those set the caption
' immediately if not already (val_* only set the default caption), so must be
' called after offset_int.

'------------------------------ Value modifiers --------------------------------

' Value modifiers can be called either before or after val_*, but must be called
' before edit_* or as_* or setting the caption!

' Cause `value` to be offset from the underlying data field.
sub EditorKit.offset_int(offset as integer)
	assert(cur_item.dtype = dtypeNone orelse cur_item.dtype = dtypeInt)
	assert(len(cur_item.caption) = 0)
	assert(edited = NO)
	cur_item.offset = offset
	' If val_* hasn't been called yet this has no effect because it'll be clobbered
	value += offset
end sub

' Convenience wrapper for one-line definitions like:
'   edit_as_enemy offset_int(1, rec(42)), Or_None
' But note you MUST NOT use this with defint!!
function EditorKit.offset_int(offset as integer, byref datum as integer) as integer
	offset_int offset
	return val_int(datum)
end function

' Invert the meaning of a bit from its underlying data field.
' As a shortcut you can prefix the menu item title with ! instead, like
'  defbitset "!Inns revive dead heroes", bits(), , 4
sub EditorKit.invert_bool()
	assert(cur_item.dtype = dtypeNone orelse cur_item.dtype = dtypeBool)
	assert(len(cur_item.caption) = 0)
	assert(edited = NO)
	cur_item.inverted_bool = YES
	' If val_* hasn't been called yet this has no effect because it'll be clobbered
	value xor= YES
end sub

' Convenience wrapper for one-line definitions like:
'   defitem "Translucent:"
'   edit_bool invert_bool(box.opaque)
' But note you MUST NOT use this with defbool!!
function EditorKit.invert_bool(byref datum as bool) as bool
	invert_bool
	return val_bool(datum)
end function

'------------------------------- Primitive types -------------------------------

' These primitive val_* functions apply data modifiers such as .offset and
' .invert_bool only if .dtype = dtypeNone, and set .writer only if it's
' writerNone. So other val_* functions should set .writer (if not None) but not
' .dtype, then call these primitives with 'value' to apply modifiers.
' Don't overwrite .writer if already set, because val_* functions can be called
' repeatedly, such as from inside edit_*.

function EditorKit.val_int(byref datum as integer) as integer
	value = datum
	with cur_item
		if .dtype = dtypeNone then
			' Need to make sure we only do this once!
			value += .offset
		end if
		.dtype = dtypeInt
		if .writer = writerNone then
			.writer = writerInt
			.int_ptr = @datum
		end if
	end with
	return value
end function

function EditorKit.val_bool(byref datum as bool) as bool
	value = (datum <> 0)
	with cur_item
		if .dtype = dtypeNone then
			' Need to make sure we only do this once!
			value xor= .inverted_bool
		end if
		.dtype = dtypeBool
		if .writer = writerNone then
			.writer = writerInt
			.int_ptr = @datum
		end if
	end with
	return value
end function

function EditorKit.val_bool(byref datum as boolean) as bool
	value = datum
	with cur_item
		if .dtype = dtypeNone then
			' Need to make sure we only do this once!
			value xor= .inverted_bool
		end if
		.dtype = dtypeBool
		if .writer = writerNone then
			.writer = writerBoolean
			.byte_ptr = @datum
		end if
	end with
	return value
end function

function EditorKit.val_bit(byref bits as integer, whichbit as integer) as bool
	value = (bits and whichbit) <> 0
	with cur_item
		if .dtype = dtypeNone then
			' Need to make sure we only do this once!
			value xor= .inverted_bool
		end if
		.dtype = dtypeBool
		if .writer = writerNone then
			.writer = writerBit
			.int_ptr = @bits
			.whichbit = whichbit
		end if
	end with
	return value
end function

function EditorKit.val_bitset(bitwords() as integer, wordnum as integer = 0, bitnum as integer) as bool
	' It's a safe bet bitwords() won't be redimmed
	'value = readbit(bitwords(), wordnum, bitnum) <> 0
	'... setbit bitwords(), wordnum, bitnum, value
	return val_bit(bitwords(wordnum + bitnum \ 16), 1 shl (bitnum mod 16))
end function

function EditorKit.val_str(byref datum as string) as string
	valuestr = datum
	with cur_item
		.dtype = dtypeStr
		if .writer = writerNone then
			.writer = writerStr
			.str_ptr = @datum
		end if
	end with
	return datum
end function

function EditorKit.val_float(byref datum as double) as double
	valuefloat = datum
	with cur_item
		.dtype = dtypeFloat
		if .writer = writerNone then
			.writer = writerDouble
			.double_ptr = @datum
		end if
	end with
	return datum
end function

'-------------------------------- Derived types --------------------------------

/'
' WARNING: options() will point to keys() strings! This is pretty dangerous so I'll comment it.
sub make_stringenum_array(options() as StringEnumOption, keys() as string)
	redim options(lbound(keys) to ubound(keys))
	for idx as integer = lbound(options) to ubound(options)
		options(idx).key = @keys(idx)
	next
end sub
'/

function find_enum_index(key as string, options() as StringEnumOption) as integer
	for idx as integer = lbound(options) to ubound(options)
		if *options(idx).key = key then
			return idx
		end if
	next
	return lbound(options) - 1
end function

' A string which takes one of a fixed set of allowed values, each of which may have a
' caption for display.
' If the string is blank but "" isn't an allowed value then it's initialised to options(0).key.
' lbound(options) can be a value other than 0.
function EditorKit.val_str_enum(byref datum as string, options() as StringEnumOption) as string
	val_str datum
	if refresh or process then
		' Ensure valuestr is valid and set caption
		dim index as integer = find_enum_index(valuestr, options())
		if index < lbound(options) then
			if len(valuestr) = 0 then
				' Apparently uninitialised, but "" is not one of the allowed
				' values, so initialise it to first option.
				' (Not using the default value for writerNodePathStr)
				if lbound(options) <= 0 andalso ubound(options) >= 0 then
					valuestr = *options(0).key
					index = 0
					edited = YES
				end if
			else
				' Invalid value, maybe from a future engine version. Don't touch it!
				if len(cur_item.caption) = 0 then
					set_caption "Unknown value: " & valuestr
				end if
			end if
		end if
		if len(cur_item.caption) = 0 andalso index >= lbound(options) then
			with options(index)
				set_caption iif(len(*.caption), .caption, .key)
			end with
		end if
	end if
	return valuestr
end function

'-------------------------------- RELOAD Nodes ---------------------------------

function EditorKit.val_node_int(node as Node ptr) as integer
	with cur_item
		if .writer = writerNone then
			.writer = writerNodeInt
			.node = node
		end if
	end with
	return val_int(GetInteger(node))  'Adds .offset
end function

' Note: `default` is the default value for a missing node, *before* adding any
' offset (so the default for `value` is `default + offset`)
function EditorKit.val_node_int(root as Node ptr, path as zstring ptr, default as integer = 0, delete_if_default_flag as EKFlags = 0) as integer
	' Wrap this in "if refresh or process or hover then"?
	with cur_item
		if .writer = writerNone then
			.writer = writerNodePathInt
			.node = root
			.path = *path
			.writer_default_int = default
			.delete_default = (delete_if_default_flag = Delete_If_Default)
		end if
	end with
	dim node as Node ptr = NodeByPath(root, path)
	value = iif(node, GetInteger(node), default)
	return val_int(value)  'Adds .offset
end function

' A Node written with value 0 or 1 (to match some existing RELOAD file formats)
function EditorKit.val_node_bool(node as Node ptr) as bool
	with cur_item
		if .writer = writerNone then
			.writer = writerNodeBool
			.node = node
		end if
	end with
	return val_bool(GetInteger(node))
end function

' Note: `default` is the default value for a missing node, *before* applying invert_bool
function EditorKit.val_node_bool(root as Node ptr, path as zstring ptr, default as bool = NO) as bool
	with cur_item
		if .writer = writerNone then
			.writer = writerNodePathBool
			.node = root
			.path = *path
			.delete_default = NO
		end if
	end with
	' Wrap this in "if refresh or process or hover then"?
	dim node as Node ptr = NodeByPath(root, path)
	value = iif(node, GetInteger(node), default)
	return val_bool(value)
end function

function EditorKit.val_node_str(node as Node ptr) as string
	with cur_item
		if .writer = writerNone then
			.writer = writerNodeStr
			.node = node
		end if
	end with
	return val_str(GetString(node))
end function

function EditorKit.val_node_str(root as Node ptr, path as zstring ptr, default as zstring ptr = @"", delete_if_default_flag as EKFlags = 0) as string
	with cur_item
		if process andalso .writer = writerNone then
			.writer = writerNodePathStr
			.node = root
			.path = *path
			.writer_default_str = *default
			.delete_default = (delete_if_default_flag = Delete_If_Default)
		end if
	end with
	dim node as Node ptr = NodeByPath(root, path)
	valuestr = iif(node, GetString(node), *default)
	return val_str(valuestr)
end function

function EditorKit.val_node_float(node as Node ptr) as double
	with cur_item
		if .writer = writerNone then
			.writer = writerNodeFloat
			.node = node
		end if
	end with
	return val_float(GetFloat(node))
end function

function EditorKit.val_node_float(root as Node ptr, path as zstring ptr, default as double = 0., delete_if_default_flag as EKFlags = 0) as double
	with cur_item
		if process andalso .writer = writerNone then
			.writer = writerNodePathFloat
			.node = root
			.path = *path
			.writer_default_float = default
			.delete_default = (delete_if_default_flag = Delete_If_Default)
		end if
	end with
	dim node as Node ptr = NodeByPath(root, path)
	return val_float(iif(node, GetFloat(node), default))
end function

function EditorKit.val_node_exists(root as Node ptr, path as zstring ptr) as bool
	with cur_item
		if process andalso .writer = writerNone then
			.writer = writerNodePathExists
			.node = root
			.path = *path
		end if
	end with
	return val_bool(NodeByPath(root, path) <> NULL)
end function

'------------------------- gen() and general.reld data -------------------------

' TODO

'-------------------------- .ini config file settings --------------------------

' Note: `default` is the default value for a missing setting, *before* applying invert_bool
function EditorKit.val_config_bool(path as zstring ptr, default as bool = NO) as bool
	' TODO: we ought to cache the config value and only read it when refreshing
	with cur_item
		if .writer = writerNone then
			' TODO: what about writing it with optional .edit prefix?
			.writer = writerConfigBool
			.path = *path
		end if
	end with
	return val_bool(read_config_bool(path, default))
end function

' TODO

'===============================================================================
'                                 Data editing

'------------------------------- Primitive types -------------------------------

function EditorKit.edit_int(byref datum as integer, min as integer, max as integer) as bool
	val_int datum
	if process then
		edited or= intgrabber(value, min, max)
		if edited then write_value
	end if
	return edited
end function

function EditorKit.edit_bool(byref datum as bool) as bool
	val_bool datum
	' Note: boolgrabber checks enter_space_click, and sets state.need_update
	if process orelse activate then
		' TODO: boolgrabber doesn't support Left/Right keys or anything else where inversion matters
		edited or= boolgrabber(value, state)
		if edited then write_value
	end if
	return edited
end function

function EditorKit.edit_bool(byref datum as boolean) as bool
	val_bool datum
	return edit_bool(value)
end function

' Editing a bit of an integer variable. (value will be true/YES or false/NO)
function EditorKit.edit_bit(byref bits as integer, whichbit as integer) as bool
	val_bit bits, whichbit
	' It's simpler to reuse edit_bool than to create a method that uses bitgrabber
	return edit_bool(value)
end function

' Editing a bit in an array of shorts. (value will be true/YES or false/NO)
function EditorKit.edit_bitset(bitwords() as integer, wordnum as integer = 0, bitnum as integer) as bool
	val_bitset bitwords(), wordnum, bitnum
	' It's simpler to reuse edit_bool than to create a method that uses bitsetgrabber
	return edit_bool(value)
end function

' See also multiline_editable()
function EditorKit.edit_str(byref datum as string, maxlen as integer = 0) as bool
	val_str datum
	if process_text then
		using_strgrabber = YES  'Disable select-by-typing
		edited or= strgrabber(valuestr, iif(maxlen, maxlen, 9999999))
		if edited then write_value
	end if
	if process then
		' Special case: SPACE should never activate a menu item that also takes text input
		' such as one that's multiline_editable.
		if keyval(scSpace) then activate = NO
	end if
	return edited
end function

'-------------------------------- Derived types --------------------------------

' For integers where values < 0 are special values and -1 is None. Like
' zintgrabber but not offset by 1: you have to use offset_int for that.
' When value < 0, you can type in a number as if value = 0.
' Backspace/Delete on value <= 0 goes to -1.
function EditorKit.edit_zint(byref datum as integer, min as integer, max as integer) as bool
	val_int datum
	if process then
		value += 1
		edited or= zintgrabber(value, min, max)
		value -= 1
		if edited then write_value
	end if
	return edited
end function

' Returns whether key was modified
function prompt_for_enum(byref key as string, prompt_text as string, options() as StringEnumOption, helpkey as string) as bool
	dim menu() as string
	dim start_idx as integer
	for idx as integer = 0 TO ubound(options)
		with options(idx)
			a_append menu(), iif(len(*.caption), .caption, .key)
			if key = *.key then start_idx = idx
		end with
	next
	dim choice as integer
	choice = popup_choice(prompt_text, menu(), start_idx, -1, helpkey)
	if choice = -1 then return NO
	key = *options(choice).key
	return YES
end function

' String enumerations: selection of a string value only from a set of allowed values.
' If the string is blank but "" isn't an allowed value then it's initialised to options(0).key.
' lbound(options) can be a value other than 0.
function EditorKit.edit_str_enum(byref datum as string, options() as StringEnumOption) as bool
	val_str_enum datum, options()
	if activate then
		edited or= prompt_for_enum(valuestr, "", options(), cur_item.helpkey)
	elseif process then
		dim index as integer = find_enum_index(valuestr, options())
		' If valuestr is not in options() then index = lbound - 1, and we preserve
		' it instead of clamping to lbound, but the user can still edit it.
		if intgrabber(index, small(index, lbound(options)), ubound(options)) then
			valuestr = *options(index).key
			edited = YES
		end if
	end if
	if edited then write_value
	return edited
end function

'-------------------------------- RELOAD Nodes ---------------------------------

function EditorKit.edit_node_int(node as Node ptr, min as integer = 0, max as integer) as bool
	val_node_int node
	return edit_int(value, min, max)
end function

' Note: `default` is the default value for a missing node, *before* applying offset_int
' Delete_If_Default flag: if passed, delete the node if it's set to the default value
function EditorKit.edit_node_int(root as Node ptr, path as zstring ptr, default as integer = 0, min as integer = 0, max as integer, delete_if_default_flag as EKFlags = 0) as bool
	val_node_int root, path, default, delete_if_default_flag
	return edit_int(value, min, max)
end function

function EditorKit.edit_node_bool(node as Node ptr) as bool
	val_node_bool node
	return edit_bool(value)
end function

' Note: `default` is the default value for a missing node, *before* applying invert_bool
function EditorKit.edit_node_bool(root as Node ptr, path as zstring ptr, default as integer = 0) as bool
	val_node_bool root, path, default
	return edit_bool(value)
end function

function EditorKit.edit_node_str(node as Node ptr, maxlen as integer = 0) as bool
	val_node_str node
	return edit_str(valuestr, maxlen)
end function

' If Delete_If_Default flag is passed, delete the node if it's set to the default value
function EditorKit.edit_node_str(root as Node ptr, path as zstring ptr, default as zstring ptr = @"", maxlen as integer = 0, delete_if_default_flag as EKFlags = 0) as bool
	val_node_str root, path, default, delete_if_default_flag
	return edit_str(valuestr, maxlen)
end function

' Toggle whether a node exists
function EditorKit.edit_node_exists(node as Node ptr, path as zstring ptr) as bool
	val_node_exists node, path
	return edit_bool(value)
end function

' Delete a Node if user presses delete/backspace, with optional prompt.
' If node is null then the node specified to the previous val/edit_node_* function is used.
' You can pass a Node ptr for non-
' If thingname is null then no prompt will be shown.
' Doesn't update value/valuestr/valuefloat.
sub EditorKit.deletable_node(node as Node ptr = NULL, thingname as zstring ptr = NULL)
	if delete_action() then
		if thingname = NULL orelse yesno("Really delete this " & *thingname & "?", NO, NO) then
			if node then
				if cur_item.node = null then
					cur_item.writer = writerNodeInt  'Dummy
					cur_item.node = node
				else
					assert(node = cur_item.node)
				end if
			end if
			delete_node
		end if
	end if
end sub

' Deletes the node specified by any val_node_* function, if it exists
sub EditorKit.delete_node()
	with cur_item
		select case as const .writer
			case writerNodeInt, writerNodeBool, writerNodeStr, writerNodeFloat
				FreeNode .node
			case writerNodePathInt, writerNodePathBool, writerNodePathStr, writerNodePathFloat, writerNodePathExists
				dim valnode as Node ptr
				valnode = NodeByPath(.node, .path)
				if valnode then FreeNode valnode
			case else
				showbug "EditorKit.delete_node: not a node datatype!"
		end select
		dont_write
	end with
	edited = YES
end sub

'----------------------------- general.reld data ------------------------------

' TODO

'-------------------------- .ini config file settings --------------------------

' Note: `default` is the default value for a missing setting, *before* applying invert_bool
function EditorKit.edit_config_bool(path as zstring ptr, default as bool = NO) as bool
	val_config_bool path, default
	return edit_bool(value)
end function

' TODO: Many more

'===============================================================================
'                     Game data type definitions & editing

' It's OK for edit_as_* functions to call as_* first, setting the caption before
' editing the value, because refreshing and processing are separate phases.

'------------------------------------ Tags -------------------------------------

' If you need more control over the captions, you can call tag_*_caption directly.
' allowspecial: if true, don't warn about picking autoset tags.

' Caption: "<prefix> #=ON/OFF (<tagname>)" where <tagname> is <zerocap> or
' "Never"/"Always" for tags 0/1.  You may want to pass zerocap="Always".
sub EditorKit.as_check_tag(byref datum as integer, prefix as zstring ptr = @"Tag", zerocap as zstring ptr = @"None")
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		wrap_caption tag_condition_caption(eff_value, *prefix, *zerocap)
	end if
end sub

' For a tag=on/off check.
function EditorKit.edit_as_check_tag(byref datum as integer, prefix as zstring ptr = @"Tag", zerocap as zstring ptr = @"None", allowneg as bool = YES) as bool
	as_check_tag datum, prefix, zerocap
	if process then
		edited or= tag_grabber(value, state, YES, NO, allowneg)  'allowspecial=YES, always_choice=NO
		if edited then write_value
	end if
	return edited
end function

' Caption: "<prefix> #=ON/OFF [AUTOSET] (<tagname>)" where <tagname> is
' "No tag set" or "Unchangeable" for tags 0/1.
sub EditorKit.as_set_tag(byref datum as integer, prefix as zstring ptr = @"Set tag", allowspecial as bool = NO)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		wrap_caption tag_set_caption(eff_value, *prefix, allowspecial)
	end if
end sub

' For setting a tag or defining an autoset tag (for autosets use allowspecial=YES, allowneg=NO).
function EditorKit.edit_as_set_tag(byref datum as integer, prefix as zstring ptr = @"Set tag", allowspecial as bool = NO, allowneg as bool = YES) as bool
	as_set_tag datum, prefix, allowspecial
	if process then
		' With our default args, equivalent to tag_set_grabber
		edited or= tag_grabber(value, state, allowspecial, , allowneg)
		if edited then write_value
	end if
	return edited
end function

' Caption: "<prefix> # [AUTOSET] (<tagname>)" where <tagname> is "None" or
' "Unchangeable" for tags 0/1.
sub EditorKit.as_tag_id(byref datum as integer, prefix as zstring ptr = @"Tag", allowspecial as bool = NO)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		wrap_caption tag_choice_caption(eff_value, *prefix, allowspecial)  'no zerocap arg
	end if
end sub

' For selecting a tag ID without negative values or "=ON/OFF" in the caption, e.g. for toggling a tag.
function EditorKit.edit_as_tag_id(byref datum as integer, prefix as zstring ptr = @"Tag", allowspecial as bool = NO) as bool
	as_tag_id datum, prefix, allowspecial
	if process then
		' This differs from tag_id_grabber, which may be misnamed
		edited or= tag_grabber(value, state, allowspecial, NO, NO)  'always_choice=NO, allowneg=NO
		if edited then write_value
	end if
	return edited
end function

'----------------------------------- Sprites -----------------------------------

sub EditorKit.as_spriteset(byref datum as integer, or_none_flag as EKFlags = 0)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		var id = eff_value
		if id = -1 andalso (or_none_flag = Or_None) then
			wrap_caption "None"
		elseif id < 0 then
			wrap_caption "Invalid spriteset " & id
		end if
	end if
end sub

function EditorKit.edit_as_spriteset(byref datum as integer, spr_type as SpriteType, or_none_flag as EKFlags = 0) as bool
	BUG_IF(spr_type < 0 or spr_type > ubound(sprite_sizes), "Bad spr_type", NO)
	as_spriteset datum, or_none_flag
	if activate then
		dim spriteb as SpriteOfTypeBrowser
		value = spriteb.browse(value, or_none_flag = Or_None, spr_type)
		edited = YES
	else
		edit_zint value, iif(or_none_flag = Or_None, -1, 0), sprite_sizes(spr_type).lastrec
	end if
	if edited then write_value
	return edited
end function

' -1 means default
sub EditorKit.as_palette(byref datum as integer)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		var id = eff_value
		if id = -1 then
			wrap_caption "Default"
		elseif id < 0 then
			wrap_caption "Invalid palette " & id
		end if
	end if
end sub

' Always allows -1 as default (there's no existing data field that doesn't allow a default palette)
function EditorKit.edit_as_palette(byref datum as integer, spr_type as SpriteType = sprTypeInvalid, spr_set as integer = 0) as bool
	BUG_IF(spr_type < 0 or spr_type > ubound(sprite_sizes), "Bad spr_type", NO)
	as_palette datum
	' Can't enter the browser without a spriteset to preview
	if activate andalso spr_type <> sprTypeInvalid then
		value = pal16browse(value, spr_type, spr_set, YES)  'show_default = YES
		edited = YES
	else
		edit_zint value, -1, gen(genMaxPal)
	end if
	if edited then write_value
	return edited
end function

'------------------------------------ Audio ------------------------------------

' -1 is Silence, but -2 often also has a special meaning which you need to set with set_caption
sub EditorKit.as_song(byref datum as integer)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		var id = eff_value
		if id < 0 then  '-1
			wrap_caption "Silence"
		else
			wrap_caption id & " " & getsongname(id)
		end if
	end if
end sub

' Assumes min <= -1 (otherwise would need to call song_picker instead)
function EditorKit.edit_as_song(byref datum as integer, min as integer = -1, preview_audio_flag as EKFlags = 0) as bool
	as_song datum
	if activate then
		value = song_picker_or_none(value + 1) - 1  'Is offset by 1 from song_picker
		edited = YES
	else
		edit_zint value, min, gen(genMaxSong)
	end if
	if edited then
		if preview_audio_flag = Preview_Audio andalso eff_value >= 0 then
			' playsongnum doesn't stop the music if you play a nonexistent song.
			music_stop
			playsongnum eff_value
		else
			music_stop
		end if
	end if
	if edited then write_value
	return edited
end function

sub EditorKit.as_sfx(byref datum as integer)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		var id = eff_value
		if id < 0 then  '-1
			wrap_caption "None"
		else
			wrap_caption id & " " & getsfxname(id)
		end if
	end if
end sub

' Assumes min <= -1 (otherwise would need to call sfx_picker instead)
function EditorKit.edit_as_sfx(byref datum as integer, min as integer = -1, preview_audio_flag as EKFlags = 0) as bool
	as_sfx datum
	if activate then
		value = sfx_picker_or_none(value + 1) - 1  'Is offset by 1 from sfx_picker
		edited = YES
	else
		edit_zint value, min, gen(genMaxSFX)
	end if
	if edited then
		if preview_audio_flag = Preview_Audio andalso eff_value >= 0 then
			resetsfx  'Stop previous sound
			playsfx eff_value
		else
			resetsfx
		end if
	end if
	if edited then write_value
	return edited
end function

'----------------------------------- Enemies -----------------------------------

' Or_None: -1 is None
sub EditorKit.as_enemy(byref datum as integer, or_none_flag as EKFlags = 0)
	val_int datum
	if refresh andalso len(cur_item.caption) = 0 then
		var id = eff_value
		if id = -1 andalso (or_none_flag = Or_None) then
			wrap_caption "None"
		elseif id < 0 then
			wrap_caption "Invalid enemy " & id
		else
			dim enemy as EnemyDef
			loadenemydata enemy, id
			wrap_caption id & " " & enemy.name
		end if
	end if
end sub

function EditorKit.edit_as_enemy(byref datum as integer, or_none_flag as EKFlags = 0) as bool
	as_enemy datum, or_none_flag
	if process then
		' TODO: offset and min args probably wrong
		edited or= enemygrabber(value, state, iif(or_none_flag = Or_None, 1, 0), 0)
		if edited then write_value
	end if
	return edited
end function

'------------------------------ Extra data vectors -----------------------------

'Adds a set of menu items for editing an extra data vector
sub EditorKit.edit_extra_data_vector(byref extravec as integer vector)
	dim length as integer = iif(extravec, v_len(extravec), 3)
	defitem "Length:"
	if edit_int(length, 0, maxExtraLength) then
		resize_extra extravec, length
	end if
	for i as integer = 0 to small(10, length) - 1
		defitem "extra " & i & ":"
		dim datum as integer = get_extra(extravec, i)
		if edit_int(datum, INT_MIN, INT_MAX) then
			set_extra extravec, i, datum
		end if
	next
	if length > 10 then
		defunselectable "..."
	end if
	if defitem_act("View/edit all extra data...") then
		extra_data_editor extravec
	end if
end sub