浏览代码

Merge pull request #75047 from KoBeWi/#new_#feature_#tags_#much_#wow

Add project tags
Yuri Sizov 2 年之前
父节点
当前提交
f6db010f5d
共有 4 个文件被更改,包括 409 次插入17 次删除
  1. 1 0
      core/config/project_settings.cpp
  2. 27 0
      editor/editor_themes.cpp
  3. 324 16
      editor/project_manager.cpp
  4. 57 1
      editor/project_manager.h

+ 1 - 0
core/config/project_settings.cpp

@@ -1251,6 +1251,7 @@ ProjectSettings::ProjectSettings() {
 	GLOBAL_DEF_BASIC("application/config/name", "");
 	GLOBAL_DEF_BASIC(PropertyInfo(Variant::DICTIONARY, "application/config/name_localized", PROPERTY_HINT_LOCALIZABLE_STRING), Dictionary());
 	GLOBAL_DEF_BASIC(PropertyInfo(Variant::STRING, "application/config/description", PROPERTY_HINT_MULTILINE_TEXT), "");
+	GLOBAL_DEF_INTERNAL(PropertyInfo(Variant::STRING, "application/config/tags"), PackedStringArray());
 	GLOBAL_DEF_BASIC(PropertyInfo(Variant::STRING, "application/run/main_scene", PROPERTY_HINT_FILE, "*.tscn,*.scn,*.res"), "");
 	GLOBAL_DEF("application/run/disable_stdout", false);
 	GLOBAL_DEF("application/run/disable_stderr", false);

+ 27 - 0
editor/editor_themes.cpp

@@ -939,6 +939,33 @@ Ref<Theme> create_editor_theme(const Ref<Theme> p_theme) {
 	editor_log_button_pressed->set_border_color(accent_color);
 	theme->set_stylebox("pressed", "EditorLogFilterButton", editor_log_button_pressed);
 
+	// ProjectTag
+	{
+		theme->set_type_variation("ProjectTag", "Button");
+
+		Ref<StyleBoxFlat> tag = style_widget->duplicate();
+		tag->set_bg_color(dark_theme ? tag->get_bg_color().lightened(0.2) : tag->get_bg_color().darkened(0.2));
+		tag->set_corner_radius(CORNER_TOP_LEFT, 0);
+		tag->set_corner_radius(CORNER_BOTTOM_LEFT, 0);
+		tag->set_corner_radius(CORNER_TOP_RIGHT, 4);
+		tag->set_corner_radius(CORNER_BOTTOM_RIGHT, 4);
+		theme->set_stylebox("normal", "ProjectTag", tag);
+
+		tag = style_widget_hover->duplicate();
+		tag->set_corner_radius(CORNER_TOP_LEFT, 0);
+		tag->set_corner_radius(CORNER_BOTTOM_LEFT, 0);
+		tag->set_corner_radius(CORNER_TOP_RIGHT, 4);
+		tag->set_corner_radius(CORNER_BOTTOM_RIGHT, 4);
+		theme->set_stylebox("hover", "ProjectTag", tag);
+
+		tag = style_widget_pressed->duplicate();
+		tag->set_corner_radius(CORNER_TOP_LEFT, 0);
+		tag->set_corner_radius(CORNER_BOTTOM_LEFT, 0);
+		tag->set_corner_radius(CORNER_TOP_RIGHT, 4);
+		tag->set_corner_radius(CORNER_BOTTOM_RIGHT, 4);
+		theme->set_stylebox("pressed", "ProjectTag", tag);
+	}
+
 	// MenuBar
 	theme->set_stylebox("normal", "MenuBar", style_widget);
 	theme->set_stylebox("hover", "MenuBar", style_widget_hover);

+ 324 - 16
editor/project_manager.cpp

@@ -51,6 +51,8 @@
 #include "main/main.h"
 #include "scene/gui/center_container.h"
 #include "scene/gui/check_box.h"
+#include "scene/gui/color_rect.h"
+#include "scene/gui/flow_container.h"
 #include "scene/gui/line_edit.h"
 #include "scene/gui/margin_container.h"
 #include "scene/gui/panel_container.h"
@@ -979,8 +981,7 @@ void ProjectListItemControl::_notification(int p_what) {
 			project_title->add_theme_font_size_override("font_size", get_theme_font_size(SNAME("title_size"), SNAME("EditorFonts")));
 			project_title->add_theme_color_override("font_color", get_theme_color(SNAME("font_color"), SNAME("Tree")));
 			project_path->add_theme_color_override("font_color", get_theme_color(SNAME("font_color"), SNAME("Tree")));
-			project_unsupported_features->add_theme_font_override("font", get_theme_font(SNAME("title"), SNAME("EditorFonts")));
-			project_unsupported_features->add_theme_color_override("font_color", get_theme_color(SNAME("warning_color"), SNAME("Editor")));
+			project_unsupported_features->set_texture(get_theme_icon(SNAME("NodeWarning"), SNAME("EditorIcons")));
 
 			favorite_button->set_texture_normal(get_theme_icon(SNAME("Favorites"), SNAME("EditorIcons")));
 			if (project_is_missing) {
@@ -1021,6 +1022,14 @@ void ProjectListItemControl::set_project_path(const String &p_path) {
 	project_path->set_text(p_path);
 }
 
+void ProjectListItemControl::set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list) {
+	for (const String &tag : p_tags) {
+		ProjectTag *tag_control = memnew(ProjectTag(tag));
+		tag_container->add_child(tag_control);
+		tag_control->connect_button_to(callable_mp(p_parent_list, &ProjectList::add_search_tag).bind(tag));
+	}
+}
+
 void ProjectListItemControl::set_project_icon(const Ref<Texture2D> &p_icon) {
 	icon_needs_reload = false;
 
@@ -1036,8 +1045,7 @@ void ProjectListItemControl::set_project_icon(const Ref<Texture2D> &p_icon) {
 void ProjectListItemControl::set_unsupported_features(const PackedStringArray &p_features) {
 	if (p_features.size() > 0) {
 		String unsupported_features_str = String(", ").join(p_features);
-		project_unsupported_features->set_text(unsupported_features_str);
-		project_unsupported_features->set_custom_minimum_size(Size2(unsupported_features_str.length() * 15, 10) * EDSCALE);
+		project_unsupported_features->set_tooltip_text(TTR("The project uses features unsupported by the current build:") + "\n" + unsupported_features_str);
 		project_unsupported_features->show();
 	} else {
 		project_unsupported_features->hide();
@@ -1133,7 +1141,7 @@ ProjectListItemControl::ProjectListItemControl() {
 	ec->set_mouse_filter(MOUSE_FILTER_PASS);
 	main_vbox->add_child(ec);
 
-	// Top half, title and unsupported features labels.
+	// Top half, title, tags and unsupported features labels.
 	{
 		HBoxContainer *title_hb = memnew(HBoxContainer);
 		main_vbox->add_child(title_hb);
@@ -1144,12 +1152,8 @@ ProjectListItemControl::ProjectListItemControl() {
 		project_title->set_clip_text(true);
 		title_hb->add_child(project_title);
 
-		project_unsupported_features = memnew(Label);
-		project_unsupported_features->set_name("ProjectUnsupportedFeatures");
-		project_unsupported_features->set_clip_text(true);
-		project_unsupported_features->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_RIGHT);
-		title_hb->add_child(project_unsupported_features);
-		project_unsupported_features->hide();
+		tag_container = memnew(HBoxContainer);
+		title_hb->add_child(tag_container);
 
 		Control *spacer = memnew(Control);
 		spacer->set_custom_minimum_size(Size2(10, 10));
@@ -1175,6 +1179,16 @@ ProjectListItemControl::ProjectListItemControl() {
 		project_path->set_h_size_flags(Control::SIZE_EXPAND_FILL);
 		project_path->set_modulate(Color(1, 1, 1, 0.5));
 		path_hb->add_child(project_path);
+
+		project_unsupported_features = memnew(TextureRect);
+		project_unsupported_features->set_name("ProjectUnsupportedFeatures");
+		project_unsupported_features->set_stretch_mode(TextureRect::STRETCH_KEEP_CENTERED);
+		path_hb->add_child(project_unsupported_features);
+		project_unsupported_features->hide();
+
+		Control *spacer = memnew(Control);
+		spacer->set_custom_minimum_size(Size2(10, 10));
+		path_hb->add_child(spacer);
 	}
 }
 
@@ -1194,6 +1208,8 @@ struct ProjectListComparator {
 				return a.path < b.path;
 			case ProjectList::EDIT_DATE:
 				return a.last_edited > b.last_edited;
+			case ProjectList::TAGS:
+				return a.tag_sort_string < b.tag_sort_string;
 			default:
 				return a.project_name < b.project_name;
 		}
@@ -1260,7 +1276,7 @@ ProjectList::Item ProjectList::load_project_data(const String &p_path, bool p_fa
 	int config_version = 0;
 	String project_name = TTR("Unnamed Project");
 	if (cf_err == OK) {
-		String cf_project_name = static_cast<String>(cf->get_value("application", "config/name", ""));
+		String cf_project_name = cf->get_value("application", "config/name", "");
 		if (!cf_project_name.is_empty()) {
 			project_name = cf_project_name.xml_unescape();
 		}
@@ -1273,6 +1289,7 @@ ProjectList::Item ProjectList::load_project_data(const String &p_path, bool p_fa
 	}
 
 	const String description = cf->get_value("application", "config/description", "");
+	const PackedStringArray tags = cf->get_value("application", "config/tags", PackedStringArray());
 	const String icon = cf->get_value("application", "config/icon", "");
 	const String main_scene = cf->get_value("application", "run/main_scene", "");
 
@@ -1299,7 +1316,11 @@ ProjectList::Item ProjectList::load_project_data(const String &p_path, bool p_fa
 		print_line("Project is missing: " + conf);
 	}
 
-	return Item(project_name, description, p_path, icon, main_scene, unsupported_features, last_edited, p_favorite, grayed, missing, config_version);
+	for (const String &tag : tags) {
+		ProjectManager::get_singleton()->add_new_tag(tag);
+	}
+
+	return Item(project_name, description, tags, p_path, icon, main_scene, unsupported_features, last_edited, p_favorite, grayed, missing, config_version);
 }
 
 void ProjectList::migrate_config() {
@@ -1427,6 +1448,7 @@ void ProjectList::_create_project_item_control(int p_index) {
 	hb->set_project_title(!item.missing ? item.project_name : TTR("Missing Project"));
 	hb->set_project_path(item.path);
 	hb->set_tooltip_text(item.description);
+	hb->set_tags(item.tags, this);
 	hb->set_unsupported_features(item.unsupported_features);
 
 	hb->set_is_favorite(item.favorite);
@@ -1462,13 +1484,33 @@ void ProjectList::sort_projects() {
 	sorter.compare.order_option = _order_option;
 	sorter.sort(_projects.ptrw(), _projects.size());
 
+	String search_term;
+	PackedStringArray tags;
+
+	if (!_search_term.is_empty()) {
+		PackedStringArray search_parts = _search_term.split(" ");
+		if (search_parts.size() > 1 || search_parts[0].begins_with("tag:")) {
+			PackedStringArray remaining;
+			for (const String &part : search_parts) {
+				if (part.begins_with("tag:")) {
+					tags.push_back(part.get_slice(":", 1));
+				} else {
+					remaining.append(part);
+				}
+			}
+			search_term = String(" ").join(remaining); // Search term without tags.
+		} else {
+			search_term = _search_term;
+		}
+	}
+
 	for (int i = 0; i < _projects.size(); ++i) {
 		Item &item = _projects.write[i];
 
 		bool item_visible = true;
 		if (!_search_term.is_empty()) {
 			String search_path;
-			if (_search_term.contains("/")) {
+			if (search_term.contains("/")) {
 				// Search path will match the whole path
 				search_path = item.path;
 			} else {
@@ -1476,8 +1518,16 @@ void ProjectList::sort_projects() {
 				search_path = item.path.get_file();
 			}
 
-			// When searching, display projects whose name or path contain the search term
-			item_visible = item.project_name.findn(_search_term) != -1 || search_path.findn(_search_term) != -1;
+			bool missing_tags = false;
+			for (const String &tag : tags) {
+				if (!item.tags.has(tag)) {
+					missing_tags = true;
+					break;
+				}
+			}
+
+			// When searching, display projects whose name or path contain the search term and whose tags match the searched tags.
+			item_visible = !missing_tags && (search_term.is_empty() || item.project_name.findn(search_term) != -1 || search_path.findn(search_term) != -1);
 		}
 
 		item.control->set_visible(item_visible);
@@ -1877,8 +1927,13 @@ void ProjectManager::_notification(int p_what) {
 			open_btn->set_icon(get_theme_icon(SNAME("Edit"), SNAME("EditorIcons")));
 			run_btn->set_icon(get_theme_icon(SNAME("Play"), SNAME("EditorIcons")));
 			rename_btn->set_icon(get_theme_icon(SNAME("Rename"), SNAME("EditorIcons")));
+			manage_tags_btn->set_icon(get_theme_icon("Script", "EditorIcons"));
 			erase_btn->set_icon(get_theme_icon(SNAME("Remove"), SNAME("EditorIcons")));
 			erase_missing_btn->set_icon(get_theme_icon(SNAME("Clear"), SNAME("EditorIcons")));
+			create_tag_btn->set_icon(get_theme_icon("Add", "EditorIcons"));
+
+			tag_error->add_theme_color_override("font_color", get_theme_color("error_color", "Editor"));
+			tag_edit_error->add_theme_color_override("font_color", get_theme_color("error_color", "Editor"));
 
 			create_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
 			import_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
@@ -1886,6 +1941,7 @@ void ProjectManager::_notification(int p_what) {
 			open_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
 			run_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
 			rename_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
+			manage_tags_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
 			erase_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
 			erase_missing_btn->add_theme_constant_override("h_separation", get_theme_constant(SNAME("sidebar_button_icon_separation"), SNAME("ProjectManager")));
 
@@ -1999,6 +2055,7 @@ void ProjectManager::_update_project_buttons() {
 	erase_btn->set_disabled(empty_selection);
 	open_btn->set_disabled(empty_selection || is_missing_project_selected);
 	rename_btn->set_disabled(empty_selection || is_missing_project_selected);
+	manage_tags_btn->set_disabled(empty_selection || is_missing_project_selected || selected_projects.size() > 1);
 	run_btn->set_disabled(empty_selection || is_missing_project_selected);
 
 	erase_missing_btn->set_disabled(!_project_list->is_any_project_missing());
@@ -2389,6 +2446,115 @@ void ProjectManager::_rename_project() {
 	}
 }
 
+void ProjectManager::_manage_project_tags() {
+	for (int i = 0; i < project_tags->get_child_count(); i++) {
+		project_tags->get_child(i)->queue_free();
+	}
+
+	const ProjectList::Item item = _project_list->get_selected_projects()[0];
+	current_project_tags = item.tags;
+	for (const String &tag : current_project_tags) {
+		ProjectTag *tag_control = memnew(ProjectTag(tag, true));
+		project_tags->add_child(tag_control);
+		tag_control->connect_button_to(callable_mp(this, &ProjectManager::_delete_project_tag).bind(tag));
+	}
+
+	tag_edit_error->hide();
+	tag_manage_dialog->popup_centered(Vector2i(500, 0) * EDSCALE);
+}
+
+void ProjectManager::_add_project_tag(const String &p_tag) {
+	if (current_project_tags.has(p_tag)) {
+		return;
+	}
+	current_project_tags.append(p_tag);
+
+	ProjectTag *tag_control = memnew(ProjectTag(p_tag, true));
+	project_tags->add_child(tag_control);
+	tag_control->connect_button_to(callable_mp(this, &ProjectManager::_delete_project_tag).bind(p_tag));
+}
+
+void ProjectManager::_delete_project_tag(const String &p_tag) {
+	current_project_tags.erase(p_tag);
+	for (int i = 0; i < project_tags->get_child_count(); i++) {
+		ProjectTag *tag_control = Object::cast_to<ProjectTag>(project_tags->get_child(i));
+		if (tag_control && tag_control->get_tag() == p_tag) {
+			memdelete(tag_control);
+			break;
+		}
+	}
+}
+
+void ProjectManager::_apply_project_tags() {
+	ProjectList::Item &item = _project_list->get_selected_projects().write[0];
+
+	PackedStringArray tags;
+	for (int i = 0; i < project_tags->get_child_count(); i++) {
+		ProjectTag *tag_control = Object::cast_to<ProjectTag>(project_tags->get_child(i));
+		if (tag_control) {
+			tags.append(tag_control->get_tag());
+		}
+	}
+
+	ConfigFile cfg;
+	String project_godot = item.path.path_join("project.godot");
+	Error err = cfg.load(project_godot);
+	if (err != OK) {
+		tag_edit_error->set_text(vformat(TTR("Couldn't load project at '%s' (error %d). It may be missing or corrupted."), project_godot, err));
+		tag_edit_error->show();
+		callable_mp((Window *)tag_manage_dialog, &Window::show).call_deferred(); // Make sure the dialog does not disappear.
+		return;
+	} else {
+		cfg.set_value("application", "config/tags", tags);
+		err = cfg.save(project_godot);
+		if (err != OK) {
+			tag_edit_error->set_text(vformat(TTR("Couldn't save project at '%s' (error %d)."), project_godot, err));
+			tag_edit_error->show();
+			callable_mp((Window *)tag_manage_dialog, &Window::show).call_deferred();
+			return;
+		}
+	}
+
+	_on_projects_updated();
+}
+
+void ProjectManager::_set_new_tag_name(const String p_name) {
+	create_tag_dialog->get_ok_button()->set_disabled(true);
+	if (p_name.is_empty()) {
+		tag_error->set_text(TTR("Tag name can't be empty."));
+		return;
+	}
+
+	if (p_name.contains(" ")) {
+		tag_error->set_text(TTR("Tag name can't contain spaces."));
+		return;
+	}
+
+	for (const String &c : forbidden_tag_characters) {
+		if (p_name.contains(c)) {
+			tag_error->set_text(vformat(TTR("These characters are not allowed in tags: %s."), String(" ").join(forbidden_tag_characters)));
+			return;
+		}
+	}
+
+	if (p_name.to_lower() != p_name) {
+		tag_error->set_text(TTR("Tag name must be lowercase."));
+		return;
+	}
+
+	tag_error->set_text("");
+	create_tag_dialog->get_ok_button()->set_disabled(false);
+}
+
+void ProjectManager::_create_new_tag() {
+	if (!tag_error->get_text().is_empty()) {
+		return;
+	}
+	create_tag_dialog->hide(); // When using text_submitted, need to hide manually.
+	add_new_tag(new_tag_name->get_text());
+	_add_project_tag(new_tag_name->get_text());
+}
+
 void ProjectManager::_erase_project_confirm() {
 	_project_list->erase_selected_projects(false);
 	_update_project_buttons();
@@ -2546,6 +2712,36 @@ void ProjectManager::_version_button_pressed() {
 	DisplayServer::get_singleton()->clipboard_set(version_btn->get_text());
 }
 
+LineEdit *ProjectManager::get_search_box() {
+	return search_box;
+}
+
+void ProjectManager::add_new_tag(const String &p_tag) {
+	if (!tag_set.has(p_tag)) {
+		tag_set.insert(p_tag);
+		ProjectTag *tag_control = memnew(ProjectTag(p_tag));
+		all_tags->add_child(tag_control);
+		all_tags->move_child(tag_control, -2);
+		tag_control->connect_button_to(callable_mp(this, &ProjectManager::_add_project_tag).bind(p_tag));
+	}
+}
+
+void ProjectList::add_search_tag(const String &p_tag) {
+	const String tag_string = "tag:" + p_tag;
+
+	int exists = _search_term.find(tag_string);
+	if (exists > -1) {
+		_search_term = _search_term.erase(exists, tag_string.length() + 1);
+	} else if (_search_term.is_empty() || _search_term.ends_with(" ")) {
+		_search_term += tag_string;
+	} else {
+		_search_term += " " + tag_string;
+	}
+	ProjectManager::get_singleton()->get_search_box()->set_text(_search_term);
+
+	sort_projects();
+}
+
 ProjectManager::ProjectManager() {
 	singleton = this;
 
@@ -2673,6 +2869,7 @@ ProjectManager::ProjectManager() {
 		sort_filter_titles.push_back(TTR("Last Edited"));
 		sort_filter_titles.push_back(TTR("Name"));
 		sort_filter_titles.push_back(TTR("Path"));
+		sort_filter_titles.push_back(TTR("Tags"));
 
 		for (int i = 0; i < sort_filter_titles.size(); i++) {
 			filter_option->add_item(sort_filter_titles[i]);
@@ -2734,6 +2931,10 @@ ProjectManager::ProjectManager() {
 		rename_btn->connect("pressed", callable_mp(this, &ProjectManager::_rename_project));
 		tree_vb->add_child(rename_btn);
 
+		manage_tags_btn = memnew(Button);
+		manage_tags_btn->set_text(TTR("Manage Tags"));
+		tree_vb->add_child(manage_tags_btn);
+
 		erase_btn = memnew(Button);
 		erase_btn->set_text(TTR("Remove"));
 		erase_btn->set_shortcut(ED_SHORTCUT("project_manager/remove_project", TTR("Remove Project"), Key::KEY_DELETE));
@@ -2925,6 +3126,75 @@ ProjectManager::ProjectManager() {
 		_build_icon_type_cache(get_theme());
 	}
 
+	{
+		// Tag management.
+		tag_manage_dialog = memnew(ConfirmationDialog);
+		add_child(tag_manage_dialog);
+		tag_manage_dialog->set_title(TTR("Manage Project Tags"));
+		tag_manage_dialog->get_ok_button()->connect("pressed", callable_mp(this, &ProjectManager::_apply_project_tags));
+		manage_tags_btn->connect("pressed", callable_mp(this, &ProjectManager::_manage_project_tags));
+
+		VBoxContainer *tag_vb = memnew(VBoxContainer);
+		tag_manage_dialog->add_child(tag_vb);
+
+		Label *label = memnew(Label(TTR("Project Tags")));
+		tag_vb->add_child(label);
+		label->set_theme_type_variation("HeaderMedium");
+		label->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
+
+		label = memnew(Label(TTR("Click tag to remove it from the project.")));
+		tag_vb->add_child(label);
+		label->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
+
+		project_tags = memnew(HFlowContainer);
+		tag_vb->add_child(project_tags);
+		project_tags->set_custom_minimum_size(Vector2(0, 100) * EDSCALE);
+
+		tag_vb->add_child(memnew(HSeparator));
+
+		label = memnew(Label(TTR("All Tags")));
+		tag_vb->add_child(label);
+		label->set_theme_type_variation("HeaderMedium");
+		label->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
+
+		label = memnew(Label(TTR("Click tag to add it to the project.")));
+		tag_vb->add_child(label);
+		label->set_horizontal_alignment(HORIZONTAL_ALIGNMENT_CENTER);
+
+		all_tags = memnew(HFlowContainer);
+		tag_vb->add_child(all_tags);
+		all_tags->set_custom_minimum_size(Vector2(0, 100) * EDSCALE);
+
+		tag_edit_error = memnew(Label);
+		tag_vb->add_child(tag_edit_error);
+		tag_edit_error->set_autowrap_mode(TextServer::AUTOWRAP_WORD);
+
+		create_tag_dialog = memnew(ConfirmationDialog);
+		tag_manage_dialog->add_child(create_tag_dialog);
+		create_tag_dialog->set_title(TTR("Create New Tag"));
+		create_tag_dialog->get_ok_button()->connect("pressed", callable_mp(this, &ProjectManager::_create_new_tag));
+
+		tag_vb = memnew(VBoxContainer);
+		create_tag_dialog->add_child(tag_vb);
+
+		Label *info = memnew(Label(TTR("Tags are capitalized automatically when displayed.")));
+		tag_vb->add_child(info);
+
+		new_tag_name = memnew(LineEdit);
+		tag_vb->add_child(new_tag_name);
+		new_tag_name->connect("text_changed", callable_mp(this, &ProjectManager::_set_new_tag_name));
+		new_tag_name->connect("text_submitted", callable_mp(this, &ProjectManager::_create_new_tag).unbind(1));
+		create_tag_dialog->connect("about_to_popup", callable_mp(new_tag_name, &LineEdit::clear));
+		create_tag_dialog->connect("about_to_popup", callable_mp((Control *)new_tag_name, &Control::grab_focus), CONNECT_DEFERRED);
+
+		tag_error = memnew(Label);
+		tag_vb->add_child(tag_error);
+
+		create_tag_btn = memnew(Button);
+		all_tags->add_child(create_tag_btn);
+		create_tag_btn->connect("pressed", callable_mp((Window *)create_tag_dialog, &Window::popup_centered).bind(Vector2i(500, 0) * EDSCALE));
+	}
+
 	_project_list->migrate_config();
 	_load_recent_projects();
 
@@ -2984,3 +3254,41 @@ ProjectManager::~ProjectManager() {
 		EditorSettings::destroy();
 	}
 }
+
+void ProjectTag::_notification(int p_what) {
+	if (display_close && p_what == NOTIFICATION_THEME_CHANGED) {
+		button->set_icon(get_theme_icon(SNAME("close"), SNAME("TabBar")));
+	}
+}
+
+ProjectTag::ProjectTag(const String &p_text, bool p_display_close) {
+	add_theme_constant_override(SNAME("separation"), 0);
+	set_v_size_flags(SIZE_SHRINK_CENTER);
+	tag_string = p_text;
+	display_close = p_display_close;
+
+	Color tag_color = Color(1, 0, 0);
+	tag_color.set_ok_hsl_s(0.8);
+	tag_color.set_ok_hsl_h(float(p_text.hash() * 10001 % UINT32_MAX) / float(UINT32_MAX));
+	set_self_modulate(tag_color);
+
+	ColorRect *cr = memnew(ColorRect);
+	add_child(cr);
+	cr->set_custom_minimum_size(Vector2(4, 0) * EDSCALE);
+	cr->set_color(tag_color);
+
+	button = memnew(Button);
+	add_child(button);
+	button->set_text(p_text.capitalize());
+	button->set_focus_mode(FOCUS_NONE);
+	button->set_icon_alignment(HORIZONTAL_ALIGNMENT_RIGHT);
+	button->set_theme_type_variation(SNAME("ProjectTag"));
+}
+
+void ProjectTag::connect_button_to(const Callable &p_callable) {
+	button->connect(SNAME("pressed"), p_callable, CONNECT_DEFERRED);
+}
+
+const String ProjectTag::get_tag() const {
+	return tag_string;
+}

+ 57 - 1
editor/project_manager.h

@@ -40,7 +40,9 @@
 class CheckBox;
 class EditorAssetLibrary;
 class EditorFileDialog;
+class HFlowContainer;
 class PanelContainer;
+class ProjectList;
 
 class ProjectDialog : public ConfirmationDialog {
 	GDCLASS(ProjectDialog, ConfirmationDialog);
@@ -144,7 +146,8 @@ class ProjectListItemControl : public HBoxContainer {
 	TextureRect *project_icon = nullptr;
 	Label *project_title = nullptr;
 	Label *project_path = nullptr;
-	Label *project_unsupported_features = nullptr;
+	TextureRect *project_unsupported_features = nullptr;
+	HBoxContainer *tag_container = nullptr;
 
 	bool project_is_missing = false;
 	bool icon_needs_reload = true;
@@ -161,6 +164,7 @@ protected:
 public:
 	void set_project_title(const String &p_title);
 	void set_project_path(const String &p_path);
+	void set_tags(const PackedStringArray &p_tags, ProjectList *p_parent_list);
 	void set_project_icon(const Ref<Texture2D> &p_icon);
 	void set_unsupported_features(const PackedStringArray &p_features);
 
@@ -184,12 +188,15 @@ public:
 		EDIT_DATE,
 		NAME,
 		PATH,
+		TAGS,
 	};
 
 	// Can often be passed by copy
 	struct Item {
 		String project_name;
 		String description;
+		PackedStringArray tags;
+		String tag_sort_string;
 		String path;
 		String icon;
 		String main_scene;
@@ -206,6 +213,7 @@ public:
 
 		Item(const String &p_name,
 				const String &p_description,
+				const PackedStringArray &p_tags,
 				const String &p_path,
 				const String &p_icon,
 				const String &p_main_scene,
@@ -217,6 +225,7 @@ public:
 				int p_version) {
 			project_name = p_name;
 			description = p_description;
+			tags = p_tags;
 			path = p_path;
 			icon = p_icon;
 			main_scene = p_main_scene;
@@ -227,6 +236,10 @@ public:
 			missing = p_missing;
 			version = p_version;
 			control = nullptr;
+
+			PackedStringArray sorted_tags = tags;
+			sorted_tags.sort();
+			tag_sort_string = String().join(sorted_tags);
 		}
 
 		_FORCE_INLINE_ bool operator==(const Item &l) const {
@@ -298,6 +311,7 @@ public:
 	void erase_missing_projects();
 
 	void set_search_term(String p_search_term);
+	void add_search_tag(const String &p_tag);
 	void set_order_option(int p_option);
 
 	void update_dock_menu();
@@ -330,6 +344,7 @@ class ProjectManager : public Control {
 	Button *open_btn = nullptr;
 	Button *run_btn = nullptr;
 	Button *rename_btn = nullptr;
+	Button *manage_tags_btn = nullptr;
 	Button *erase_btn = nullptr;
 	Button *erase_missing_btn = nullptr;
 	Button *about_btn = nullptr;
@@ -337,6 +352,8 @@ class ProjectManager : public Control {
 	HBoxContainer *local_projects_hb = nullptr;
 	EditorAssetLibrary *asset_library = nullptr;
 
+	Ref<StyleBox> tag_stylebox;
+
 	EditorFileDialog *scan_dir = nullptr;
 	ConfirmationDialog *language_restart_ask = nullptr;
 
@@ -365,6 +382,18 @@ class ProjectManager : public Control {
 	OptionButton *language_btn = nullptr;
 	LinkButton *version_btn = nullptr;
 
+	HashSet<String> tag_set;
+	PackedStringArray current_project_tags;
+	PackedStringArray forbidden_tag_characters{ "/", "\\", "-" };
+	ConfirmationDialog *tag_manage_dialog = nullptr;
+	HFlowContainer *project_tags = nullptr;
+	HFlowContainer *all_tags = nullptr;
+	Label *tag_edit_error = nullptr;
+	Button *create_tag_btn = nullptr;
+	ConfirmationDialog *create_tag_dialog = nullptr;
+	LineEdit *new_tag_name = nullptr;
+	Label *tag_error = nullptr;
+
 	void _open_asset_library();
 	void _scan_projects();
 	void _run_project();
@@ -386,6 +415,13 @@ class ProjectManager : public Control {
 	void _restart_confirm();
 	void _confirm_update_settings();
 
+	void _manage_project_tags();
+	void _add_project_tag(const String &p_tag);
+	void _delete_project_tag(const String &p_tag);
+	void _apply_project_tags();
+	void _set_new_tag_name(const String p_name);
+	void _create_new_tag();
+
 	void _load_recent_projects();
 	void _on_project_created(const String &dir);
 	void _on_projects_updated();
@@ -414,8 +450,28 @@ protected:
 public:
 	static ProjectManager *get_singleton() { return singleton; }
 
+	LineEdit *get_search_box();
+	void add_new_tag(const String &p_tag);
+
 	ProjectManager();
 	~ProjectManager();
 };
 
+class ProjectTag : public HBoxContainer {
+	GDCLASS(ProjectTag, HBoxContainer);
+
+	String tag_string;
+	Button *button = nullptr;
+	bool display_close = false;
+
+protected:
+	void _notification(int p_what);
+
+public:
+	ProjectTag(const String &p_text, bool p_display_close = false);
+
+	void connect_button_to(const Callable &p_callable);
+	const String get_tag() const;
+};
+
 #endif // PROJECT_MANAGER_H