diff --git a/library/general/src/lib/ui/delayed_progress_popup.rb b/library/general/src/lib/ui/delayed_progress_popup.rb new file mode 100644 index 000000000..7336b2936 --- /dev/null +++ b/library/general/src/lib/ui/delayed_progress_popup.rb @@ -0,0 +1,242 @@ +# ------------------------------------------------------------------------------ +# Copyright (c) 2022 SUSE LLC +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of version 2 of the GNU General Public License as published by the +# Free Software Foundation. +# +# This program 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 General Public License for more details. +# +# ------------------------------------------------------------------------------ + +require "yast2/system_time" +require "yast" + +module Yast + # Progress popup dialog that only opens after a certain delay, so it never + # opens for very short operations (< 4 seconds by default), only when an + # operation takes long enough to actually give feedback to the user. + # + # This is less disruptive than a progress dialog that always opens, and in + # most cases, flashes by so fast that the user can't recognize what it says. + # + # The tradeoff is that it takes a few seconds until there is any visual + # feedback (until the delay is expired). + # + # Notice that this does not use an active timer; the calling application has + # to trigger the check for the timeout by calling progress() in regular + # intervals. + # + # You can change the delay by changing the delay_seconds member variable, you + # can force the dialog to open with open!, and you can stop and (re-) start + # the timer. + # + # In any case, when done with this progress reporting, call close(). You + # don't need to check if it ever opened; close() does that automatically. + # + # see examples/delayed_progress_1.rb for a usage example. + # + class DelayedProgressPopup + include Yast::UIShortcuts + include Yast::Logger + + # @return [String] Text for the dialog heading. Default: nil. + attr_accessor :heading + + # @return [Integer] Delay (timeout) in seconds. + attr_accessor :delay_seconds + + # @return [Integer] Percent (0..100) that are considered "almost done" + # so the dialog is not opened anymore if it isn't already. Default: 80 + # Set this to 100 to disable that. + attr_accessor :almost_done_percent + + # @return [Boolean] Add a "Cancel" button to the dialog. Default: true. + attr_accessor :use_cancel_button + + # Constructor. + # + # If `auto_start` is `true` (default), this also starts the timer with a + # default (4 seconds) timeout. + # + # The `close` method must be explicitly called at the end when the progress + # is finished. + # + # @param delay [Integer,nil] optional delay in seconds + # @param auto_start [Boolean] start the timer immediately + # @param heading [String,nil] optional popup heading + def initialize(delay: nil, auto_start: true, heading: nil) + Yast.import "UI" + Yast.import "Label" + + @delay_seconds = delay || 4 + @heading = heading + @use_cancel_button = true + @almost_done_percent = 80 + @is_open = false + start_timer if auto_start + log.info "Created delayed progress popup" + end + + # A static variant with block, it automatically closes the popup at the end. + # + # @param delay [Integer,nil] optional delay in seconds + # @param heading [String,nil] optional popup heading + # @example + # Yast::DelayedProgressPopup.run(delay: 5, heading: "Working...") do |popup| + # 10.times do |sec| + # popup.progress(10 * sec, "Working #{sec}") + # sleep(1) + # end + # end + def self.run(delay: nil, auto_start: true, heading: nil, &block) + popup = new(delay: delay, auto_start: auto_start, heading: heading) + block.call(popup) + ensure + popup&.close + end + + # Update the progress. + # + # If the dialog is not open yet, this opens it if the timeout is expired; + # unless the whole process is almost done anyway, i.e. at the time when the + # dialog would be opened, progress_percent is already at the + # @almost_done_percent threshold, so it would just open and then close + # almost immediately again. + # + # @param [Integer] progress_percent numeric progress bar value + # @param [nil|String] progress_text optional progress bar label text + # + def progress(progress_percent, progress_text = nil) + log.info "progress_percent: #{progress_percent}" + open_if_needed unless progress_percent >= @almost_done_percent + return unless open? + + update_progress(progress_percent, progress_text) + end + + # Open the dialog if needed, i.e. if it's not already open and if the timer + # expired. + # + # Notice that progress() does this automatically. + # + def open_if_needed + return if open? + + open! if timer_expired? + end + + # Open the dialog unconditionally. + def open! + log.info "Opening the delayed progress popup" + UI.OpenDialog(dialog_widgets) + @is_open = true + stop_timer + end + + # Close the dialog if it is open. Only stop the timer if it is not (because + # the timer didn't expire). + # + # Do not call this if another dialog was opened on top of this one in the + # meantime: Just like a normal UI.CloseDialog call, this closes the topmost + # dialog; which in that case might not be the right one. + # + def close + stop_timer + return unless open? + + UI.CloseDialog + @is_open = false + end + + # Start or restart the timer. + def start_timer + @start_time = Yast2::SystemTime.uptime + end + + # Stop the timer. + def stop_timer + @start_time = nil + end + + # Check if the dialog is open. + def open? + @is_open + end + + # Check if the timer expired. + def timer_expired? + return false unless timer_running? + + now = Yast2::SystemTime.uptime + now > @start_time + delay_seconds + end + + # Check if the timer is running. + def timer_running? + !@start_time.nil? + end + + protected + + # Return a widget term for the dialog widgets. + # Reimplement this in inherited classes for a different dialog content. + # + def dialog_widgets + placeholder_label = " " # at least one blank + heading_spacing = @heading.nil? ? 0 : 0.4 + MinWidth( + 40, + VBox( + MarginBox( + 1, 0.4, + VBox( + dialog_heading, + VSpacing(heading_spacing), + VCenter( + ProgressBar(Id(:progress_bar), placeholder_label, 100, 0) + ) + ) + ), + VSpacing(0.4), + dialog_buttons + ) + ) + end + + # Return a widget term for the dialog heading. + def dialog_heading + return Empty() if @heading.nil? + + Left(Heading(@heading)) + end + + # Return a widget term for the dialog buttons. + # Reimplement this in inherited classes for different buttons. + # + # Notice that the buttons only do anything if the calling application + # handles them, e.g. with UI.PollInput(). + # + # Don't forget that in the Qt UI, every window has a WM_CLOSE button (the + # [x] icon in the window title bar that is meant for closing the window) + # that returns :cancel in UI.UserInput() / UI.PollInput(). + # + def dialog_buttons + return Empty() unless @use_cancel_button + + ButtonBox( + PushButton(Id(:cancel), Opt(:cancelButton), Yast::Label.CancelButton) + ) + end + + # Update the progress bar. + def update_progress(progress_percent, progress_text = nil) + return unless UI.WidgetExists(:progress_bar) + + UI.ChangeWidget(Id(:progress_bar), :Value, progress_percent) + UI.ChangeWidget(Id(:progress_bar), :Label, progress_text) unless progress_text.nil? + end + end +end diff --git a/library/general/src/lib/ui/examples/delayed_progress_1.rb b/library/general/src/lib/ui/examples/delayed_progress_1.rb new file mode 100644 index 000000000..a1d120827 --- /dev/null +++ b/library/general/src/lib/ui/examples/delayed_progress_1.rb @@ -0,0 +1,37 @@ +# Example for the DelayedProgressPopup +# +# Start with: +# +# y2start ./delayed_progress_1.rb qt +# or +# y2start ./delayed_progress_1.rb ncurses +# + +require "yast" +require "ui/delayed_progress_popup" + +popup = Yast::DelayedProgressPopup.new + +# All those parameters are optional; +# comment out or uncomment to experiment. +popup.heading = "Deep Think Mode" +popup.delay_seconds = 2 +# popup.use_cancel_button = false + +puts("Nothing happens for #{popup.delay_seconds} seconds, then the popup opens.") + +10.times do |sec| + puts "#{sec} sec" + popup.progress(10 * sec, "Working #{sec}") + if popup.open? + # Checking for popup.open? is only needed here because otherwise there is + # no window at all yet, so UI.WaitForEvent() throws an exception. Normal + # applications have a main window at this point. + + event = Yast::UI.WaitForEvent(1000) # implicitly sleeps + break if event["ID"] == :cancel + else + sleep(1) + end +end +popup.close diff --git a/library/general/src/lib/ui/examples/delayed_progress_2.rb b/library/general/src/lib/ui/examples/delayed_progress_2.rb new file mode 100644 index 000000000..ea0c0a610 --- /dev/null +++ b/library/general/src/lib/ui/examples/delayed_progress_2.rb @@ -0,0 +1,35 @@ +# Example for the DelayedProgressPopup +# +# Start with: +# +# y2start ./delayed_progress_2.rb qt +# or +# y2start ./delayed_progress_2.rb ncurses +# + +require "yast" +require "ui/delayed_progress_popup" + +Yast::DelayedProgressPopup.run(delay: 2, heading: "Deep Think Mode") do |popup| + # All those parameters are optional; + # comment out or uncomment to experiment. + # popup.heading = "Deep Think Mode" + # popup.use_cancel_button = false + + puts("Nothing happens for #{popup.delay_seconds} seconds, then the popup opens.") + + 10.times do |sec| + puts "#{sec} sec" + popup.progress(10 * sec, "Working #{sec}") + if popup.open? + # Checking for popup.open? is only needed here because otherwise there is + # no window at all yet, so UI.WaitForEvent() throws an exception. Normal + # applications have a main window at this point. + + event = Yast::UI.WaitForEvent(1000) # implicitly sleeps + break if event["ID"] == :cancel + else + sleep(1) + end + end +end diff --git a/library/general/src/lib/ui/examples/delayed_progress_almost_done.rb b/library/general/src/lib/ui/examples/delayed_progress_almost_done.rb new file mode 100644 index 000000000..a32f9cad6 --- /dev/null +++ b/library/general/src/lib/ui/examples/delayed_progress_almost_done.rb @@ -0,0 +1,36 @@ +# Example for the DelayedProgressPopup +# +# Start with: +# +# y2start ./delayed_progress_almost_done.rb qt +# or +# y2start ./delayed_progress_almost_done.rb ncurses +# + +require "yast" +require "ui/delayed_progress_popup" + +Yast::DelayedProgressPopup.run(delay: 3, heading: "Deep Think Mode") do |popup| + # All those parameters are optional; + # comment out or uncomment to experiment. + # popup.heading = "Deep Think Mode" + # popup.use_cancel_button = false + + puts("This will never open, not even after the #{popup.delay_seconds} sec delay.") + + 5.times do |sec| + percent = 80 + sec + puts "#{sec} sec; progress: #{percent}%" + popup.progress(percent, "Working #{sec}") + if popup.open? + # Checking for popup.open? is only needed here because otherwise there is + # no window at all yet, so UI.WaitForEvent() throws an exception. Normal + # applications have a main window at this point. + + event = Yast::UI.WaitForEvent(1000) # implicitly sleeps + break if event["ID"] == :cancel + else + sleep(1) + end + end +end diff --git a/library/packages/src/lib/packages/file_conflict_callbacks.rb b/library/packages/src/lib/packages/file_conflict_callbacks.rb index 0069335f7..86c87cd74 100644 --- a/library/packages/src/lib/packages/file_conflict_callbacks.rb +++ b/library/packages/src/lib/packages/file_conflict_callbacks.rb @@ -1,5 +1,5 @@ # ------------------------------------------------------------------------------ -# Copyright (c) 2016 SUSE LLC +# Copyright (c) 2016-2022 SUSE LLC # # This program is free software; you can redistribute it and/or modify it under # the terms of version 2 of the GNU General Public License as published by the @@ -13,14 +13,12 @@ # require "yast" +require "ui/delayed_progress_popup" module Packages # Default file conflicts callbacks for package bindings. To register the # callbacks in Yast::Pkg just call {Packages::FileConflictCallbacks.register} class FileConflictCallbacks - # Widget ID (created by other code) - PKG_INSTALL_WIDGET = :progressCurrentPackage - class << self include Yast::Logger include Yast::I18n @@ -40,12 +38,13 @@ def register textdomain "base" + create_delayed_progress_popup unless Yast::Mode.commandline register_file_conflict_callbacks end private - # Helper function for creating an YaST function reference + # Helper function for creating a YaST function reference def fun_ref(*args) Yast::FunRef.new(*args) end @@ -64,30 +63,61 @@ def register_file_conflict_callbacks nil end - # Is the package installation progress displayed? - # @return [Boolean] true if package installation progress is displayed - def pkg_installation? - Yast::UI.WidgetExists(PKG_INSTALL_WIDGET) + # Create the DelayedProgressPopup, but don't start the timer yet. + # + # This needs to be done outside of any of the callbacks to avoid clashes + # between Ruby, C++ and ex-YCP YaST components. + # + # rubocop:disable Style/ClassVars + def create_delayed_progress_popup + log.info "Creating DelayedProgressPopup" + @@delayed_progress_popup ||= Yast::DelayedProgressPopup.new( + auto_start: false, + heading: progress_bar_label + ) + nil + end + + def delayed_progress_popup + # Intentionally NOT creating the DelayedProgressPopup here so it isn't + # accidentially created in one of the callbacks. + @@delayed_progress_popup + end + # rubocop:enable Style/ClassVars + + def progress_bar_label + # TRANSLATORS: progress bar label + _("Checking file conflicts...") + end + + # Set the label text of the global progress bar, if it exists. + # + # @param [String] text + # + def update_progress_text(text) + # This uses the widget ID of the global progress bar of the SlideShow + # module directly to keep the cross-dependencies down at least a little + # bit. + # + # On the plus side, this is so defensive (it does nothing if there is + # no such widget) that the worst thing that can happen is that there is + # that there is no immediate feedback by setting the progress bar label. + return unless Yast::UI.WidgetExists(:progressTotal) + + Yast::UI.ChangeWidget(:progressTotal, :Label, text) end # Handle the file conflict detection start callback. def start log.info "Starting the file conflict check..." - # TRANSLATORS: progress bar label - label = _("Checking file conflicts...") if Yast::Mode.commandline - Yast::CommandLine.PrintVerbose(label) - elsif pkg_installation? - # package slideshow with progress already present - Yast::UI.ChangeWidget(Id(PKG_INSTALL_WIDGET), :Value, 0) - Yast::UI.ChangeWidget(Id(PKG_INSTALL_WIDGET), :Label, label) + Yast::CommandLine.PrintVerbose(progress_bar_label) else - Yast::Wizard.CreateDialog - # TRANSLATORS: help text for the file conflict detection progress - help = _("

Detecting the file conflicts is in progress.

") - # Use the same label for the window title and the progressbar label - Yast::Progress.Simple(label, label, 100, help) + # Don't create the DelayedProgressPopup here, otherwise there will be + # conflicts between the Ruby, C++ and ex-YCP YaST components. + delayed_progress_popup.start_timer + update_progress_text(progress_bar_label) # Immediate feedback end end @@ -99,10 +129,8 @@ def progress(progress) if Yast::Mode.commandline Yast::CommandLine.PrintVerboseNoCR("#{Yast::PackageCallbacksClass::CLEAR_PROGRESS_TEXT}#{progress}%") - elsif pkg_installation? - Yast::UI.ChangeWidget(Id(PKG_INSTALL_WIDGET), :Value, progress) else - Yast::Progress.Step(progress) + delayed_progress_popup.progress(progress) end ui = Yast::UI.PollInput unless Yast::Mode.commandline @@ -112,13 +140,15 @@ def progress(progress) end # Handle the file conflict detection result callback. - # Ask to user whether to continue. In the AutoYaST mode an error is reported - # but the installation will continue ignoring the confliucts. + # Ask the user whether to continue. In AutoYaST mode, an error is + # reported, but the installation will continue ignoring the conflicts. + # # @param excluded_packages [Array] packages ignored in the check # (e.g. not available for check in the download-as-needed mode) # @param conflicts [Array] list of translated descriptions of # the detected file conflicts # @return [Boolean] true = continue, false = abort + # def report(excluded_packages, conflicts) log.info "Excluded #{excluded_packages.size} packages in file conflict check" log.debug "Excluded packages: #{excluded_packages.inspect}" @@ -149,11 +179,10 @@ def report(excluded_packages, conflicts) # Handle the file conflict detection finish callback. def finish log.info "File conflict check finished" - return if Yast::Mode.commandline || pkg_installation? + return if Yast::Mode.commandline - # finish the opened progress dialog - Yast::Progress.Finish - Yast::Wizard.CloseDialog + delayed_progress_popup.close + update_progress_text(" ") # One blank to maintain the label's height end # Construct the file conflicts dialog. diff --git a/library/packages/test/file_conflict_callbacks_test.rb b/library/packages/test/file_conflict_callbacks_test.rb index bd889488e..1a9db1294 100755 --- a/library/packages/test/file_conflict_callbacks_test.rb +++ b/library/packages/test/file_conflict_callbacks_test.rb @@ -128,13 +128,19 @@ def CallbackFileConflictFinish(func) end describe ".register" do - it "calls the Pkg methods for registering the file conflicts handlers" do - expect(dummy_pkg).to receive(:CallbackFileConflictStart).at_least(:once) - expect(dummy_pkg).to receive(:CallbackFileConflictProgress).at_least(:once) - expect(dummy_pkg).to receive(:CallbackFileConflictReport).at_least(:once) - expect(dummy_pkg).to receive(:CallbackFileConflictFinish).at_least(:once) + context "in UI mode" do + before do + allow(Yast::Mode).to receive(:commandline).and_return(false) + end - Packages::FileConflictCallbacks.register + it "calls the Pkg methods for registering the file conflicts handlers" do + expect(dummy_pkg).to receive(:CallbackFileConflictStart).at_least(:once) + expect(dummy_pkg).to receive(:CallbackFileConflictProgress).at_least(:once) + expect(dummy_pkg).to receive(:CallbackFileConflictReport).at_least(:once) + expect(dummy_pkg).to receive(:CallbackFileConflictFinish).at_least(:once) + + Packages::FileConflictCallbacks.register + end end end @@ -156,30 +162,14 @@ def CallbackFileConflictFinish(func) start_cb.call end end - - context "in UI mode" do - before do - allow(Yast::Mode).to receive(:commandline).and_return(false) - end - - it "reuses the package installation progress" do - expect(Yast::UI).to receive(:WidgetExists).and_return(true) - expect(Yast::UI).to receive(:ChangeWidget).twice - - start_cb.call - end - - it "opens a new progress if installation progress was not displayed" do - expect(Yast::UI).to receive(:WidgetExists).and_return(false) - expect(Yast::Wizard).to receive(:CreateDialog) - expect(Yast::Progress).to receive(:Simple) - - start_cb.call - end - end end describe "the registered progress callback handler" do + let(:start_cb) do + Packages::FileConflictCallbacks.register + dummy_pkg.fc_start + end + let(:progress_cb) do Packages::FileConflictCallbacks.register dummy_pkg.fc_progress @@ -216,6 +206,13 @@ def CallbackFileConflictFinish(func) allow(Yast::Mode).to receive(:commandline).and_return(false) end + it "receives the progress call" do + expect_any_instance_of(Yast::DelayedProgressPopup).to receive(:progress) + + start_cb.call + progress_cb.call(progress) + end + it "returns false to abort if user clicks Abort" do expect(Yast::UI).to receive(:PollInput).and_return(:abort) @@ -233,20 +230,6 @@ def CallbackFileConflictFinish(func) expect(progress_cb.call(progress)).to eq(true) end - - it "uses the existing widget if package installation progress was displayed" do - expect(Yast::UI).to receive(:WidgetExists).and_return(true) - expect(Yast::UI).to receive(:ChangeWidget) - - progress_cb.call(progress) - end - - it "sets the progress if package installation progress was not displayed" do - expect(Yast::UI).to receive(:WidgetExists).and_return(false) - expect(Yast::Progress).to receive(:Step).with(progress) - - progress_cb.call(progress) - end end end @@ -264,11 +247,6 @@ def CallbackFileConflictFinish(func) allow(Yast::Mode).to receive(:commandline).and_return(true) end - it "does not check the command line mode, it behaves same as in the UI mode" do - expect(Yast::Mode).to_not receive(:commandline) - report_cb.call(excluded, conflicts) - end - it "does not call any UI method" do ui = double("no method call expected") stub_const("Yast::UI", ui) @@ -357,6 +335,11 @@ def CallbackFileConflictFinish(func) end describe "the registered finish callback handler" do + let(:start_cb) do + Packages::FileConflictCallbacks.register + dummy_pkg.fc_start + end + let(:finish_cb) do Packages::FileConflictCallbacks.register dummy_pkg.fc_finish @@ -380,21 +363,12 @@ def CallbackFileConflictFinish(func) allow(Yast::Mode).to receive(:commandline).and_return(false) end - it "no change if installation progress was already displayed" do - ui = double("no method call expected", WidgetExists: true) - stub_const("Yast::UI", ui) - - finish_cb.call - end - - it "closes progress if installation progress was not displayed" do - allow(Yast::UI).to receive(:WidgetExists).and_return(false) - expect(Yast::Wizard).to receive(:CloseDialog) - expect(Yast::Progress).to receive(:Finish) + it "closes the delayed progress popup" do + expect_any_instance_of(Yast::DelayedProgressPopup).to receive(:close) + start_cb.call finish_cb.call end - end end end diff --git a/package/yast2.changes b/package/yast2.changes index baf58da5b..c66bcdd97 100644 --- a/package/yast2.changes +++ b/package/yast2.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Thu Apr 7 11:43:15 UTC 2022 - Stefan Hundhammer + +- Show file conflict checking progress in delayed popup (bsc#1195608) + PR: https://github.com/yast/yast-yast2/pull/1252 +- 4.5.1 + ------------------------------------------------------------------- Wed Apr 06 13:24:58 UTC 2022 - Ladislav Slezák diff --git a/package/yast2.spec b/package/yast2.spec index 919a1e8de..834863fbf 100644 --- a/package/yast2.spec +++ b/package/yast2.spec @@ -17,7 +17,7 @@ Name: yast2 -Version: 4.5.0 +Version: 4.5.1 Release: 0 Summary: YaST2 Main Package