% Copyright (C) 2016-2019 Olivier Boudeville % % This file is part of the Ceylan-Myriad library. % % This library is free software: you can redistribute it and/or modify % it under the terms of the GNU Lesser General Public License or % the GNU General Public License, as they are published by the Free Software % Foundation, either version 3 of these Licenses, or (at your option) % any later version. % You can also redistribute it and/or modify it under the terms of the % Mozilla Public License, version 1.1 or later. % % This library is distributed in the hope that it will be useful, % but WITHOUT ANY WARRANTY; without even the implied warranty of % MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the % GNU Lesser General Public License and the GNU General Public License % for more details. % % You should have received a copy of the GNU Lesser General Public % License, of the GNU General Public License and of the Mozilla Public License % along with this library. % If not, see and % . % % Author: Olivier Boudeville [olivier (dot) boudeville (at) esperide (dot) com] % Creation date: Wednesday, May 2, 2018 % This is the most basic, line-based monochrome textual interface, directly in % raw text with no cursor control. % % See: % - text_ui_test.erl for the corresponding test % - gui.erl for a graphical counterpart % % See also: trace_utils.erl for another kind of output. % -module(text_ui). % Implementation notes: % % In this very specific case, we use the process dictionary to avoid having to % keep around a UI-state variable in all calls. % % So now the UI state is fully implicit; counterpart functions with an explicit % state use to be also provided (ex: if having a large number of UI operations % to perform in a row), in which case they were to return an updated state, % however it proved to combersome to define and use. % Basic UI operations. % -export([ % No is_available/0, as deemed always available. start/0, start/1, set/1, set/2, unset/1, display/1, display/2, display_numbered_list/2, display_error/1, display_error/2, display_error_numbered_list/2, add_separation/0, get_text/2, get_text_as_integer/2, get_text_as_maybe_integer/2, read_text_as_integer/2, choose_designated_item/1, choose_designated_item/2, choose_designated_item/3, choose_numbered_item/1, choose_numbered_item/2, choose_numbered_item/3, choose_numbered_item_with_default/2, choose_numbered_item_with_default/3, choose_numbered_item_with_default/4, set_setting/2, set_setting/3, set_settings/1, set_settings/2, get_setting/1, trace/1, trace/2, stop/0, stop/1, to_string/0, to_string/1 ]). -record( text_ui_state, { % Path to our work-around script: get_line_script = file_utils:executable_path(), log_console = false :: boolean(), log_file = undefined :: maybe( file_utils:file() ), settings :: setting_table() }). -type ui_state() :: #text_ui_state{}. % For common, transverse defines: -include("ui.hrl"). -export_type([ ui_state/0 ]). % An I/O device, either standard_io, standard_error, a registered name, or a pid % handling I/O protocols (returned from file:open/2): % -type channel() :: io:device(). -define( error_prefix, "~n [error] " ). -define( error_suffix, "~n" ). % Starts the UI with default settings. % % Stores the corresponding state in the process dictionary. % -spec start() -> void(). start() -> start( _Opts=[] ). % Starts the UI with specified settings. % % Stores the corresponding state in the process dictionary. % -spec start( ui_options() ) -> void(). start( Options ) -> % Cached, as the look-up is a bit demanding: GetLineScript = system_utils:get_line_helper_script(), BlankUIState = #text_ui_state{ get_line_script=GetLineScript, settings=?ui_table:new() }, start( Options, BlankUIState ). % (non-exported helper) start( _Options=[], UIState ) -> % Check: undefined = process_dictionary:put( ?ui_name_key, ?MODULE ), % No prior state expected: case process_dictionary:put( ?ui_state_key, UIState ) of undefined -> ok; _ -> throw( text_ui_already_started ) end; start( _Options=[ log_file | T ], UIState ) -> start( [ { log_file, "ui.log" } | T ], UIState ); start( _Options=[ { log_file, Filename } | T ], UIState ) -> LogFile = file_utils:open( Filename, [ write, exclusive ] ), file_utils:write( LogFile, "Starting text UI.\n" ), NewUIState = UIState#text_ui_state{ log_file=LogFile }, start( T, NewUIState ); start( SingleElem, UIState ) -> start( [ SingleElem ], UIState ). % Sets specified UI setting. % -spec set( ui_setting_key(), ui_setting_value() ) -> void(). set( SettingKey, SettingValue ) -> set( [ { SettingKey, SettingValue } ] ). % Sets specified UI settings. % -spec set( [ ui_setting_entry() ] ) -> void(). set( SettingEntries ) -> UIState = #text_ui_state{ settings=SettingTable } = get_state(), NewSettingTable = ?ui_table:add_entries( SettingEntries, SettingTable ), set_state( UIState#text_ui_state{ settings=NewSettingTable } ). % Unsets specified UI setting. % -spec unset( [ ui_setting_key() ] | ui_setting_key() ) -> void(). unset( SettingKeys ) when is_list( SettingKeys ) -> UIState = #text_ui_state{ settings=SettingTable } = get_state(), NewSettingTable = ?ui_table:remove_entries( SettingKeys, SettingTable ), set_state( UIState#text_ui_state{ settings=NewSettingTable } ); unset( SettingKey ) -> UIState = #text_ui_state{ settings=SettingTable } = get_state(), NewSettingTable = ?ui_table:remove_entry( SettingKey, SettingTable ), set_state( UIState#text_ui_state{ settings=NewSettingTable } ). % Displays specified text, as a normal message. % -spec display( text() ) -> void(). display( Text ) -> display_helper( _Channel=standard_io, Text ). % Displays specified formatted text, as a normal message. % -spec display( text_utils:format_string(), [ term() ] ) -> void(). display( FormatString, Values ) -> display_helper( _Channel=standard_io, FormatString, Values ). % Displays in-order the items of specified list, as a normal message. % -spec display_numbered_list( label(), [ text() ] ) -> void(). display_numbered_list( Label, Lines ) -> LineStrings = text_utils:strings_to_enumerated_string( Lines ), display_helper( _Channel=standard_io, "~s~s", [ Label, LineStrings ] ). % Displays specified text, as an error message. % -spec display_error( text() ) -> void(). display_error( Text ) -> display_helper( standard_error, ?error_prefix ++ Text ++ ?error_suffix ). % Displays specified formatted text, as an error message. % -spec display_error( text_utils:format_string(), [ term() ] ) -> void(). display_error( FormatString, Values ) -> display_helper( standard_error, ?error_prefix ++ FormatString ++ ?error_suffix, Values ). % Displays in-order the items of specified list, as an error message. % -spec display_error_numbered_list( label(), [ text() ] ) -> void(). display_error_numbered_list( Label, Lines ) -> LineStrings = text_utils:strings_to_enumerated_string( Lines ), display_helper( _Channel=standard_error, ?error_prefix ++ "~s~s" ++ ?error_suffix, [ Label, LineStrings ] ). % Adds a default separation between previous and next content. % -spec add_separation() -> void(). add_separation() -> display( _Text="" ). % Returns the user-entered text. % % (const) % -spec get_text( prompt(), ui_state() ) -> text(). get_text( Prompt, _UIState ) -> %#text_ui_state{ get_line_script=GetLineScript } ) -> text_utils:remove_ending_carriage_return( io:get_line( Prompt ) ). %text_utils:remove_ending_carriage_return( % system_utils:get_line( Prompt, GetLineScript ) ). % Returns the user-entered text, once translated to an integer, based on an % implicit state. % % (const) % -spec get_text_as_integer( prompt(), ui_state() ) -> text(). get_text_as_integer( Prompt, UIState ) -> Text = get_text( Prompt, UIState ), text_utils:string_to_integer( Text ). % Returns the user-entered text, once translated to an integer, based on an % implicit state, prompting the user until a valid input is obtained. % % (const) % -spec read_text_as_integer( prompt(), ui_state() ) -> text(). read_text_as_integer( Prompt, UIState ) -> Text = get_text( Prompt, UIState ), case text_utils:try_string_to_integer( Text ) of undefined -> %trace_utils:debug_fmt( "(rejected: '~s')", [ Text ] ), read_text_as_integer( Prompt, UIState ); I -> I end. % Returns the user-entered text (if any), once translated to an integer. % % (const) % -spec get_text_as_maybe_integer( prompt(), ui_state() ) -> maybe( text() ). get_text_as_maybe_integer( Prompt, UIState ) -> case get_text( Prompt, UIState ) of "" -> undefined; Text -> text_utils:string_to_integer( Text ) end. % Returns the user-entered text, once translated to an integer, prompting the % user until a valid input is obtained: either a string that resolves to an % (then returned) integer, or an empty string (then returning 'undefined'). % % (const) % -spec read_text_as_maybe_integer( prompt(), ui_state() ) -> maybe( text() ). read_text_as_maybe_integer( Prompt, UIState ) -> case get_text( Prompt, UIState ) of "" -> undefined; Text -> case text_utils:try_string_to_integer( Text ) of undefined -> read_text_as_integer( Prompt, UIState ); I -> I end end. % Selects, using a default prompt, an item among the specified ones, and returns % its designator. % % (const) % -spec choose_designated_item( [ choice_element() ] ) -> choice_designator(). choose_designated_item( Choices ) -> Prompt = text_utils:format( "Select among these ~B choices:", [ length( Choices ) ] ), choose_designated_item( Prompt, Choices ). % Selects, using specified prompt, an item among the specified ones, and returns % its designator. % % (const) % choose_designated_item( Label, Choices ) -> choose_designated_item( Label, Choices, get_state() ). % Selects, based on an explicit state, using the specified label, an item among % the specified ones, and returns its designator. % % (const) % -spec choose_designated_item( label(), [ choice_element() ], ui_state() ) -> choice_designator(). choose_designated_item( Label, Choices, UIState ) -> { Designators, Texts } = lists:unzip( Choices ), ChoiceCount = length( Choices ), { _FinalCount, NumberedText } = lists:foldl( fun( Text, { Count, AccText } ) -> NewText = text_utils:format( "[~B] ~s", [ Count, Text ] ), NewAccText = [ NewText | AccText ], { Count+1, NewAccText } end, _Acc0= { 1, [] }, _List=Texts ), Text = text_utils:strings_to_string( lists:reverse( NumberedText ), _Bullet=" " ), FullLabel = text_utils:format( "~s~s~nChoice> ", [ Label, Text ] ), case read_text_as_integer( FullLabel, UIState ) of { parsing_failed, Input } -> display_error( "Input shall be an integer (not ~s).", [ Input ] ), choose_designated_item( Label, Choices, UIState ); N when N < 1 -> display_error( "Specified choice shall be at least 1 (not ~B).", [ N ] ), %throw( { invalid_choice, too_low, N } ); choose_designated_item( Label, Choices, UIState ); N when N > ChoiceCount -> display_error( "Specified choice shall not be greater than ~B (not ~B).", [ ChoiceCount, N ] ), %throw( { invalid_choice, too_high, N } ); choose_designated_item( Label, Choices, UIState ); N -> lists:nth( N, Designators ) end. % Selects, based on an implicit state, using a default label, an item among the % specified ones, and returns its index. % -spec choose_numbered_item( [ choice_element() ] ) -> choice_index(). choose_numbered_item( Choices ) -> choose_numbered_item( Choices, get_state() ). % Selects, based on an explicit state, using a default label, an item among the % specified ones, and returns its index. % % Selects, based on an implicit state, using the specified label, an item among % the specified ones, and returns its index. % -spec choose_numbered_item( [ choice_element() ], ui_state() ) -> choice_index(); ( label(), [ choice_element() ] ) -> choice_index(). choose_numbered_item( Choices, UIState ) when is_record( UIState, text_ui_state ) -> Label = text_utils:format( "Select among these ~B choices:", [ length( Choices ) ] ), choose_numbered_item( Label, Choices, UIState ); choose_numbered_item( Label, Choices ) -> choose_numbered_item( Label, Choices, get_state() ). % Selects, based on an explicit state, using the specified label, an item among % the specified ones, and returns its index. % -spec choose_numbered_item( label(), [ choice_element() ], ui_state() ) -> choice_index(). choose_numbered_item( Label, Choices, UIState ) -> ChoiceCount = length( Choices ), { _FinalCount, NumberedText } = lists:foldl( fun( Text, { Count, AccText } ) -> NewText = text_utils:format( "[~B] ~s", [ Count, Text ] ), NewAccText = [ NewText | AccText ], { Count+1, NewAccText } end, _Acc0= { 1, [] }, _List=Choices ), Text = text_utils:strings_to_string( lists:reverse( NumberedText ), _Bullet=" " ), FullLabel = text_utils:format( "~s~s~nChoice> ", [ Label, Text ] ), SelectedNumber = get_text_as_integer( FullLabel, UIState ), %trace_utils:format( "Selected: ~B", [ SelectedNumber ] ), case SelectedNumber of N when N < 1 -> display_error( "Specified choice shall be at least 1 (not ~B).", [ N ] ), %throw( { invalid_choice, too_low, N } ); choose_numbered_item( Label, Choices, UIState ); N when N > ChoiceCount -> display_error( "Specified choice shall not be greater than ~B (not ~B).", [ ChoiceCount, N ] ), %throw( { invalid_choice, too_high, N } ); choose_numbered_item( Label, Choices, UIState ); N -> N end. % Selects, based on an implicit state, using a default label, an item among the % specified ones, and returns its index. % -spec choose_numbered_item_with_default( [ choice_element() ], choice_index() ) -> choice_index(). choose_numbered_item_with_default( Choices, DefaultChoiceIndex ) -> choose_numbered_item_with_default( Choices, DefaultChoiceIndex, get_state() ). % Selects, based on an explicit state, using a default label, an item among the % specified ones, and returns its index. % % Selects, based on an implicit state, using the specified label and default % item, an item among the specified ones, and returns its index. % -spec choose_numbered_item_with_default( [ choice_element() ], choice_index(), ui_state() ) -> choice_index(); ( label(), [ choice_element() ], maybe( choice_index() ) ) -> choice_index(). choose_numbered_item_with_default( Choices, DefaultChoiceIndex, UIState ) when is_record( UIState, text_ui_state ) -> Label = text_utils:format( "Select among these ~B choices:", [ length( Choices ) ] ), choose_numbered_item_with_default( Label, Choices, DefaultChoiceIndex, UIState ); choose_numbered_item_with_default( Label, Choices, DefaultChoiceIndex ) -> choose_numbered_item_with_default( Label, Choices, DefaultChoiceIndex, get_state() ). % Selects, based on an explicit state, using the specified label and default % item, an item among the specified ones, and returns its index. % -spec choose_numbered_item_with_default( label(), [ choice_element() ], maybe( choice_index() ), ui_state() ) -> choice_index(). choose_numbered_item_with_default( Label, Choices, DefaultChoiceIndex, UIState ) -> ChoiceCount = length( Choices ), case DefaultChoiceIndex =/= undefined andalso DefaultChoiceIndex > 0 andalso DefaultChoiceIndex =< ChoiceCount of true -> ok; false -> throw( { invalid_default_index, DefaultChoiceIndex } ) end, { _FinalCount, NumberedText } = lists:foldl( fun( Text, { Count, AccText } ) -> NewText = text_utils:format( "[~B] ~s", [ Count, Text ] ), NewAccText = [ NewText | AccText ], { Count+1, NewAccText } end, _Acc0= { 1, [] }, _List=Choices ), Text = text_utils:strings_to_string( lists:reverse( NumberedText ), _Bullet=" " ), FullLabel = text_utils:format( "~s~s~nChoice [default: ~B]> ", [ Label, Text, DefaultChoiceIndex ] ), case read_text_as_maybe_integer( FullLabel, UIState ) of % Default: undefined -> DefaultChoiceIndex; N when N < 1 -> display_error( "Specified choice shall be at least 1 (not ~B).", [ N ] ), %throw( { invalid_choice, too_low, N } ); choose_numbered_item_with_default( Label, Choices, DefaultChoiceIndex, UIState ); N when N > ChoiceCount -> display_error( "Specified choice shall not be greater than ~B " "(not ~B).", [ ChoiceCount, N ] ), %throw( { invalid_choice, too_high, N } ); choose_numbered_item_with_default( Label, Choices, DefaultChoiceIndex, UIState ); N -> N end. % Traces specified message, by displaying it, and possibly logging it, based on % an implicit state. % -spec trace( message() ) -> void(). trace( Message ) -> trace( Message, get_state() ). % Traces specified message, by displaying it, and possibly logging it. % -spec trace( message(), ui_state() ) -> void(); ( text_utils:format_string(), [ term() ] ) -> void(). trace( Message, UIState ) when is_record( UIState, text_ui_state ) -> TraceMessage = "[trace] " ++ Message ++ "\n", case UIState#text_ui_state.log_console of true -> display( TraceMessage, UIState ); false -> ok end, case UIState#text_ui_state.log_file of undefined -> ok; LogFile -> display( LogFile, TraceMessage ) end; trace( FormatString, Values ) -> trace( text_utils:format( FormatString, Values ) ). % Stops the UI. % -spec stop() -> void(). stop() -> stop( get_state() ). % Stops the UI. % -spec stop( ui_state() ) -> void(). stop( #text_ui_state{ log_file=undefined } ) -> stop_helper(); stop( #text_ui_state{ log_file=LogFile } ) -> file_utils:write( LogFile, "Stopping UI.\n" ), file_utils:close( LogFile ), stop_helper(). stop_helper() -> [ process_dictionary:remove( Key ) || Key <- [ ?ui_name_key, ?ui_state_key ] ]. % Helper section. % Sets the current UI state. % % (helper) % -spec set_state( ui_state() ) -> void(). set_state( UIState ) -> process_dictionary:put( ?ui_state_key, UIState ). % Returns the current UI state. % % (helper) % -spec get_state() -> ui_state(). get_state() -> case process_dictionary:get( ?ui_state_key ) of undefined -> throw( text_ui_not_started ); UIState -> UIState end. % Displays specified text, on specified channel. % % (helper) % -spec display_helper( channel(), text() ) -> void(). display_helper( Channel, Text ) -> display_helper( Channel, Text, _Values=[] ). % Displays specified formatted text, on specified channel. % -spec display_helper( channel(), text_utils:format_string(), [ term() ] ) -> void(). display_helper( Channel, FormatString, Values ) -> %trace_utils:debug_fmt( "Displaying, on channel '~p', '~p', with '~p'.", % [ Channel, FormatString, Values ] ), UIState = get_state(), case get_setting( backtitle, UIState ) of undefined -> ok; Backtitle -> io:format( Channel, "~n [~s]~n", [ Backtitle ] ) end, case get_setting( title, UIState ) of undefined -> ok; Title -> io:format( Channel, " ~s~n", [ Title ] ) end, io:format( Channel, FormatString ++ "~n", Values ). % Sets the specified setting to specified value, in the (implicit) UI state. % -spec set_setting( ui_setting_key(), ui_setting_value() ) -> void(). set_setting( SettingKey, SettingValue ) -> NewUIState = set_setting( SettingKey, SettingValue, get_state() ), set_state( NewUIState ). % Sets the specified setting to specified value, in the specified UI state. % -spec set_setting( ui_setting_key(), ui_setting_value(), ui_state() ) -> ui_state(). set_setting( SettingKey, SettingValue, UIState=#text_ui_state{ settings=SettingTable } ) -> NewSettingTable = ?ui_table:add_entry( SettingKey, SettingValue, SettingTable ), UIState#text_ui_state{ settings=NewSettingTable }. % Sets the specified settings to specified values, in the (implicit) UI state. % -spec set_settings( [ ui_setting_entry() ] ) -> void(). set_settings( SettingEntries ) -> NewUIState = set_settings( SettingEntries, get_state() ), set_state( NewUIState ). % Sets the specified settings to specified values, in the specified UI state. % -spec set_settings( [ ui_setting_entry() ], ui_state() ) -> ui_state(). set_settings( SettingEntries, UIState=#text_ui_state{ settings=SettingTable } ) -> NewSettingTable = ?ui_table:add_entries( SettingEntries, SettingTable ), UIState#text_ui_state{ settings=NewSettingTable }. % Returns the value (if any) associated, in the (implicit) UI state, to the % specified setting. % -spec get_setting( ui_setting_key() ) -> maybe( ui_setting_value() ). get_setting( SettingKey ) -> get_setting( SettingKey, get_state() ). % Returns the value (if any) associated, in the specified UI state, to the % specified setting. % -spec get_setting( ui_setting_key(), ui_state() ) -> maybe( ui_setting_value() ). get_setting( SettingKey, #text_ui_state{ settings=SettingTable } ) -> ?ui_table:get_value_with_defaults( SettingKey, _Default=undefined, SettingTable ). % Returns a textual description of the (implicit) UI state. % -spec to_string() -> string(). to_string() -> to_string( get_state() ). % Returns a textual description of the specified UI state. % -spec to_string( ui_state() ) -> string(). to_string( #text_ui_state{ get_line_script=GetLineScript, log_console=LogConsole, log_file=LogFile, settings=SettingTable }) -> ScriptString = text_utils:format( "relying on helper script '~s'", [ GetLineScript ] ), ConsoleString = case LogConsole of true -> ""; false -> "not" end, FileString = case LogFile of undefined -> "not using a log file"; _ -> text_utils:format( "using log file '~s'", [ LogFile ] ) end, SettingString = ui:settings_to_string( SettingTable ), text_utils:format( "text_ui interface, ~s, ~s writing logs on console, " "~s and ~s", [ ScriptString, ConsoleString, FileString, SettingString ] ).