diff --git a/build/deploy-local-smapi.targets b/build/deploy-local-smapi.targets
index bd84ee11b..7ca967f84 100644
--- a/build/deploy-local-smapi.targets
+++ b/build/deploy-local-smapi.targets
@@ -24,6 +24,7 @@ This assumes `find-game-folder.targets` has already been imported and validated.
+
diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs
index 78db0d659..ccba7d545 100644
--- a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs
+++ b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs
@@ -53,20 +53,15 @@ public void WriteLine(string message, ConsoleLogLevel level)
{
if (level == ConsoleLogLevel.Critical)
{
- Console.BackgroundColor = ConsoleColor.Red;
- Console.ForegroundColor = ConsoleColor.White;
- Console.WriteLine(message);
- Console.ResetColor();
+ this.WriteLineImpl(message, ConsoleColor.White, ConsoleColor.Red);
}
else
{
- Console.ForegroundColor = this.Colors[level];
- Console.WriteLine(message);
- Console.ResetColor();
+ this.WriteLineImpl(message, this.Colors[level], null);
}
}
else
- Console.WriteLine(message);
+ this.WriteLineImpl(message, null, null);
}
/// Get the default color scheme config for cases where it's not configurable (e.g. the installer).
@@ -103,6 +98,25 @@ public static ColorSchemeConfig GetDefaultColorSchemeConfig(MonitorColorScheme u
}
+ /*********
+ ** Private methods
+ *********/
+ ///
+ /// Implementation of writing a line to the console, virtual to allow for other console implementations.
+ ///
+ /// The message to log.
+ /// The foreground color to override the default with, if any.
+ /// The background color to override the default with, if any.
+ protected virtual void WriteLineImpl(string message, ConsoleColor? foregroundColor, ConsoleColor? backgroundColor)
+ {
+ if (backgroundColor.HasValue)
+ Console.BackgroundColor = backgroundColor.Value;
+ if (foregroundColor.HasValue)
+ Console.ForegroundColor = foregroundColor.Value;
+ Console.WriteLine(message);
+ Console.ResetColor();
+ }
+
/*********
** Private methods
*********/
diff --git a/src/SMAPI/Framework/Command.cs b/src/SMAPI/Framework/Command.cs
index dca1dd09b..7ae28c6d6 100644
--- a/src/SMAPI/Framework/Command.cs
+++ b/src/SMAPI/Framework/Command.cs
@@ -20,6 +20,9 @@ internal class Command
/// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.
public Action Callback { get; }
+ /// The method to invoke for auto-complete handling. This method is passed the command name and current input, and should return the potential matches.
+ public Func? AutoCompleteHandler { get; }
+
/*********
** Public methods
@@ -29,12 +32,14 @@ internal class Command
/// The command name, which the user must type to trigger it.
/// The human-readable documentation shown when the player runs the built-in 'help' command.
/// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.
- public Command(IModMetadata? mod, string name, string documentation, Action callback)
+ /// The method to invoke for auto-complete handling. This method is passed the command name and current input, and should return the potential matches.
+ public Command(IModMetadata? mod, string name, string documentation, Action callback, Func? autoCompleteHandler)
{
this.Mod = mod;
this.Name = name;
this.Documentation = documentation;
this.Callback = callback;
+ this.AutoCompleteHandler = autoCompleteHandler;
}
}
}
diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs
index b20e5ceb8..c7a93bfc2 100644
--- a/src/SMAPI/Framework/CommandManager.cs
+++ b/src/SMAPI/Framework/CommandManager.cs
@@ -39,6 +39,20 @@ public CommandManager(IMonitor monitor)
/// The is not a valid format.
/// There's already a command with that name.
public CommandManager Add(IModMetadata? mod, string name, string documentation, Action callback)
+ {
+ return this.Add(mod, name, documentation, callback, null);
+ }
+
+ /// Add a console command.
+ /// The mod adding the command (or null for a SMAPI command).
+ /// The command name, which the user must type to trigger it.
+ /// The human-readable documentation shown when the player runs the built-in 'help' command.
+ /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.
+ /// The method to invoke for auto-complete handling. This method is passed the command name and current input, and should return the potential matches. The matches should all start with the last space-separated string of input.
+ /// The or is null or empty.
+ /// The is not a valid format.
+ /// There's already a command with that name.
+ public CommandManager Add(IModMetadata? mod, string name, string documentation, Action callback, Func? autoCompleteHandler)
{
name = this.GetNormalizedName(name)!; // null-checked below
@@ -55,7 +69,7 @@ public CommandManager Add(IModMetadata? mod, string name, string documentation,
throw new ArgumentException(nameof(callback), $"Can't register the '{name}' command because there's already a command with that name.");
// add command
- this.Commands.Add(name, new Command(mod, name, documentation, callback));
+ this.Commands.Add(name, new Command(mod, name, documentation, callback, autoCompleteHandler));
return this;
}
@@ -65,7 +79,7 @@ public CommandManager Add(IModMetadata? mod, string name, string documentation,
/// There's already a command with that name.
public CommandManager Add(IInternalCommand command, IMonitor monitor)
{
- return this.Add(null, command.Name, command.Description, (_, args) => command.HandleCommand(args, monitor));
+ return this.Add(null, command.Name, command.Description, (_, args) => command.HandleCommand(args, monitor), (_, input) => command.HandleAutocomplete(input, monitor));
}
/// Get a command by its unique name.
@@ -138,6 +152,36 @@ public bool TryParse(string? input, [NotNullWhen(true)] out string? name, [NotNu
return this.Commands.TryGetValue(name, out command);
}
+ ///
+ /// Handle autocompletion results.
+ ///
+ /// The input string to autocomplete for.
+ /// An array of matches for the input.
+ public string[] HandleAutocomplete(string input)
+ {
+ int space = input.IndexOf(' ');
+ if (space == -1)
+ {
+ List matches = new();
+ foreach (string cmd in this.Commands.Keys)
+ {
+ if (cmd.StartsWith(input))
+ matches.Add(cmd);
+ }
+ return matches.ToArray();
+ }
+ else
+ {
+ string currCmd = input.Substring(0, space);
+ if (!this.Commands.TryGetValue(currCmd, out Command? cmd) || cmd.AutoCompleteHandler == null)
+ {
+ return Array.Empty();
+ }
+
+ return cmd.AutoCompleteHandler(currCmd, input.Substring(space + 1));
+ }
+ }
+
/*********
** Private methods
diff --git a/src/SMAPI/Framework/Commands/HelpCommand.cs b/src/SMAPI/Framework/Commands/HelpCommand.cs
index 65dc3bce3..547a1ecd9 100644
--- a/src/SMAPI/Framework/Commands/HelpCommand.cs
+++ b/src/SMAPI/Framework/Commands/HelpCommand.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using System.Linq;
namespace StardewModdingAPI.Framework.Commands
@@ -72,5 +74,24 @@ public void HandleCommand(string[] args, IMonitor monitor)
monitor.Log(message, LogLevel.Info);
}
}
+
+ /// Handle the console command auto-complete when requested by the user..
+ /// The current input.
+ /// Writes messages to the console.
+ public string[] HandleAutocomplete(string input, IMonitor monitor)
+ {
+ if (input.Contains(' '))
+ return Array.Empty();
+
+ var allCommandNames = this.CommandManager.GetAll().Select(cmd => cmd.Name);
+
+ List ret = new();
+ foreach (string name in allCommandNames)
+ {
+ if (name.StartsWith(input))
+ ret.Add(name);
+ }
+ return ret.ToArray();
+ }
}
}
diff --git a/src/SMAPI/Framework/Commands/IInternalCommand.cs b/src/SMAPI/Framework/Commands/IInternalCommand.cs
index abf105b69..db1f88fbd 100644
--- a/src/SMAPI/Framework/Commands/IInternalCommand.cs
+++ b/src/SMAPI/Framework/Commands/IInternalCommand.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace StardewModdingAPI.Framework.Commands
{
/// A core SMAPI console command.
@@ -20,5 +22,14 @@ interface IInternalCommand
/// The command arguments.
/// Writes messages to the console.
void HandleCommand(string[] args, IMonitor monitor);
+
+ /// Handle the console command auto-complete when requested by the user..
+ /// The current input.
+ /// Writes messages to the console.
+ string[] HandleAutocomplete(string input, IMonitor monitor)
+ {
+ // Default implementation for if a command doesn't support it.
+ return Array.Empty();
+ }
}
}
diff --git a/src/SMAPI/Framework/ConsoleWrapperConsoleLogger.cs b/src/SMAPI/Framework/ConsoleWrapperConsoleLogger.cs
new file mode 100644
index 000000000..d5cfaff1e
--- /dev/null
+++ b/src/SMAPI/Framework/ConsoleWrapperConsoleLogger.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using ConsoleWrapperLib;
+using StardewModdingAPI.Toolkit.Utilities;
+
+namespace StardewModdingAPI.Internal.ConsoleWriting
+{
+ /// Writes color-coded text to a ConsoleWrapper object.
+ internal class ConsoleWrapperConsoleWriter : ColorfulConsoleWriter
+ {
+ /// The console wrapper object to use, if avaiable.
+ public ConsoleWrapper? ConsoleWrapper { get; set; }
+
+ /// Construct an instance.
+ /// The target platform.
+ /// The colors to use for text written to the SMAPI console.
+ public ConsoleWrapperConsoleWriter(Platform platform, ColorSchemeConfig colorConfig)
+ : base(platform, colorConfig)
+ {
+ }
+
+ ///
+ protected override void WriteLineImpl(string message, ConsoleColor? foregroundColor, ConsoleColor? backgroundColor)
+ {
+ if (this.ConsoleWrapper != null)
+ this.ConsoleWrapper.WriteLine(message, foregroundColor ?? this.ConsoleWrapper.DefaultForeground, backgroundColor ?? this.ConsoleWrapper.DefaultBackground);
+ else
+ base.WriteLineImpl(message, foregroundColor, backgroundColor);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs
index 978254651..3c3fc51bf 100644
--- a/src/SMAPI/Framework/Logging/LogManager.cs
+++ b/src/SMAPI/Framework/Logging/LogManager.cs
@@ -5,6 +5,7 @@
using System.Linq;
using System.Text;
using System.Threading;
+using ConsoleWrapperLib;
using StardewModdingAPI.Framework.Commands;
using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModLoading;
@@ -25,6 +26,16 @@ internal class LogManager : IDisposable
/// The log file to which to write messages.
private readonly LogFileManager LogFile;
+ /// If we're in legacy mode or not.
+ [MemberNotNullWhen(false, nameof(ConsoleWrapper))]
+ private bool LegacyMode { get; }
+
+ /// The console wrapper object.
+ private ConsoleWrapper? ConsoleWrapper;
+
+ /// The console writer object.
+ private IConsoleWriter ConsoleWriter;
+
/// Create a monitor instance given the ID and name.
private readonly Func GetMonitorImpl;
@@ -51,14 +62,28 @@ internal class LogManager : IDisposable
/// Whether to output log messages to the console.
/// The log contexts for which to enable verbose logging, which may show a lot more information to simplify troubleshooting.
/// Whether to enable full console output for developers.
+ /// Whether to use legacy mode or not, which enables auto completion and moves user input to always be at the bottom of the console.
/// Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.
- public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, HashSet verboseLogging, bool isDeveloperMode, Func getScreenIdForLog)
+ public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, HashSet verboseLogging, bool isDeveloperMode, bool legacyMode, Func getScreenIdForLog)
{
// init log file
this.LogFile = new LogFileManager(logPath);
+ // save legacy mode value
+ this.LegacyMode = legacyMode;
+
+ // init console
+ if (!this.LegacyMode)
+ {
+ this.ConsoleWriter = new ConsoleWrapperConsoleWriter(Constants.Platform, colorConfig);
+ }
+ else
+ {
+ this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig);
+ }
+
// init monitor
- this.GetMonitorImpl = (id, name) => new Monitor(name, this.LogFile, colorConfig, verboseLogging.Contains("*") || verboseLogging.Contains(id), getScreenIdForLog)
+ this.GetMonitorImpl = (id, name) => new Monitor(name, this.LogFile, this.ConsoleWriter, verboseLogging.Contains("*") || verboseLogging.Contains(id), getScreenIdForLog)
{
WriteToConsole = writeToConsole,
ShowTraceInConsole = isDeveloperMode,
@@ -104,13 +129,21 @@ public void RunConsoleInputLoop(CommandManager commandManager, Action reloadTran
.Add(new HarmonySummaryCommand(), this.Monitor)
.Add(new ReloadI18nCommand(reloadTranslations), this.Monitor);
+ if (!this.LegacyMode)
+ {
+ this.ConsoleWrapper = new ConsoleWrapper();
+ this.ConsoleWrapper.AutoCompleteHandler = commandManager.HandleAutocomplete;
+ if (this.ConsoleWriter is ConsoleWrapperConsoleWriter consoleWrapperWriter)
+ consoleWrapperWriter.ConsoleWrapper = this.ConsoleWrapper;
+ }
+
// start handling command line input
Thread inputThread = new(() =>
{
while (true)
{
// get input
- string? input = Console.ReadLine();
+ string? input = (this.ConsoleWrapper != null) ? this.ConsoleWrapper.ReadLine() : Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
continue;
diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs
index d3c5a1f93..fa45711c4 100644
--- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs
@@ -30,5 +30,11 @@ public ICommandHelper Add(string name, string documentation, Action
+ public ICommandHelper Add(string name, string documentation, Action callback, Func autoCompleteHandler)
+ {
+ this.CommandManager.Add(this.Mod, name, documentation, callback, autoCompleteHandler);
+ return this;
+ }
}
}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 40bdb1304..5d4c8d0b2 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -17,6 +17,7 @@ internal class SConfig
{
[nameof(CheckForUpdates)] = true,
[nameof(ListenForConsoleInput)] = true,
+ [nameof(LegacyConsoleMode)] = false,
[nameof(ParanoidWarnings)] = Constants.IsDebugBuild,
[nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(),
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
@@ -53,6 +54,9 @@ internal class SConfig
/// Whether SMAPI should listen for console input to support console commands.
public bool ListenForConsoleInput { get; set; }
+ /// Whether SMAPI should use legacy console mode. This will prevent tab auto completion from working, and will not keep input at the bottom of the console.
+ public bool LegacyConsoleMode { get; set; }
+
/// Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.
public bool ParanoidWarnings { get; set; }
@@ -107,6 +111,7 @@ internal class SConfig
///
///
///
+ ///
///
///
///
@@ -122,11 +127,12 @@ internal class SConfig
///
///
///
- public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate)
+ public SConfig(bool developerMode, bool? checkForUpdates, bool? listenForConsoleInput, bool? legacyConsoleMode, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, string[]? verboseLogging, bool? rewriteMods, bool? fixHarmony, bool? useCaseInsensitivePaths, bool? logNetworkTraffic, bool? logTechnicalDetailsForBrokenMods, ColorSchemeConfig consoleColors, bool? suppressHarmonyDebugMode, string[]? suppressUpdateChecks, string[]? modsToLoadEarly, string[]? modsToLoadLate)
{
this.DeveloperMode = developerMode;
this.CheckForUpdates = checkForUpdates ?? (bool)SConfig.DefaultValues[nameof(this.CheckForUpdates)];
this.ListenForConsoleInput = listenForConsoleInput ?? (bool)SConfig.DefaultValues[nameof(this.ListenForConsoleInput)];
+ this.LegacyConsoleMode = legacyConsoleMode ?? (bool)SConfig.DefaultValues[nameof(this.LegacyConsoleMode)];
this.ParanoidWarnings = paranoidWarnings ?? (bool)SConfig.DefaultValues[nameof(this.ParanoidWarnings)];
this.UseBetaChannel = useBetaChannel ?? (bool)SConfig.DefaultValues[nameof(this.UseBetaChannel)];
this.GitHubProjectName = gitHubProjectName;
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index cecb0040c..93bab32af 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -63,10 +63,10 @@ internal class Monitor : IMonitor
/// Construct an instance.
/// The name of the module which logs messages using this instance.
/// The log file to which to write messages.
- /// The colors to use for text written to the SMAPI console.
+ /// The console writer to use for console output.
/// Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.
/// Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.
- public Monitor(string source, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose, Func getScreenIdForLog)
+ public Monitor(string source, LogFileManager logFile, IConsoleWriter consoleWriter, bool isVerbose, Func getScreenIdForLog)
{
// validate
if (string.IsNullOrWhiteSpace(source))
@@ -75,7 +75,7 @@ public Monitor(string source, LogFileManager logFile, ColorSchemeConfig colorCon
// initialize
this.Source = source;
this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null.");
- this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig);
+ this.ConsoleWriter = consoleWriter;
this.IsVerbose = isVerbose;
this.GetScreenIdForLog = getScreenIdForLog;
}
@@ -126,9 +126,10 @@ internal void LogFatal(string message)
/// The user input to log.
internal void LogUserInput(string input)
{
- // user input already appears in the console, so just need to write to file
string prefix = this.GenerateMessagePrefix(this.Source, (ConsoleLogLevel)LogLevel.Info);
- this.LogFile.WriteLine($"{prefix} $>{input}");
+ string output = $"{prefix} $>{input}";
+ Console.WriteLine(output);
+ this.LogFile.WriteLine(output);
}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index 2b10d4259..40fafab59 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -202,7 +202,7 @@ public SCore(string modsPath, bool writeToConsole, bool? developerMode)
}
// init basics
- this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, verboseLogging: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog);
+ this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, verboseLogging: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, legacyMode: this.Settings.LegacyConsoleMode, getScreenIdForLog: this.GetScreenIdForLog);
this.CommandManager = new CommandManager(this.Monitor);
this.EventManager = new EventManager(this.ModRegistry);
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
diff --git a/src/SMAPI/ICommandHelper.cs b/src/SMAPI/ICommandHelper.cs
index afbcf2b02..ca915d321 100644
--- a/src/SMAPI/ICommandHelper.cs
+++ b/src/SMAPI/ICommandHelper.cs
@@ -16,5 +16,15 @@ public interface ICommandHelper : IModLinked
/// The is not a valid format.
/// There's already a command with that name.
ICommandHelper Add(string name, string documentation, Action callback);
+
+ /// Add a console command.
+ /// The command name, which the user must type to trigger it.
+ /// The human-readable documentation shown when the player runs the built-in 'help' command.
+ /// The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.
+ /// The method to invoke for auto-complete handling. This method is passed the command name and current input, and should return the potential matches.
+ /// The or is null or empty.
+ /// The is not a valid format.
+ /// There's already a command with that name.
+ ICommandHelper Add(string name, string documentation, Action callback, Func autoCompleteHandler);
}
}
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 55f9869b6..3ff4d1bcd 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -47,6 +47,12 @@ in future SMAPI versions.
*/
"ListenForConsoleInput": true,
+ /**
+ * Whether SMAPI should use legacy console mode. This will prevent tab auto completion from
+ * working, and will not keep input at the bottom of the console.
+ */
+ "LegacyConsoleMode": false,
+
/**
* Whether SMAPI should rewrite mods for compatibility. This may prevent older mods from
* loading, but bypasses a Visual Studio crash when debugging.
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index 426ab347d..728a3b86a 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -27,6 +27,7 @@
+